extract-tracker 0.2.2__tar.gz → 0.3.0__tar.gz
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.
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/PKG-INFO +1 -1
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/mcp.md +24 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/pyproject.toml +1 -1
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/__main__.py +38 -13
- extract_tracker-0.3.0/python/src/extract/cli_query.py +223 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/mcp.py +165 -87
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/PKG-INFO +1 -1
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/SOURCES.txt +1 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/Cargo.lock +1 -1
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/Cargo.toml +1 -1
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/app.rs +2 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/db.rs +25 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/layout.rs +8 -1
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/popup.rs +121 -13
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/summary.rs +9 -1
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/MANIFEST.in +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/README.md +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/config.md +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/packaging.md +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/sync.md +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/tui.md +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/usage.md +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/__init__.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/experiment.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/init.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/metrics.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/release_versioning.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/run.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/store.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/sync.py +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/dependency_links.txt +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/entry_points.txt +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/requires.txt +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/top_level.txt +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/artifact.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/config.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/event.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/keys.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/main.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/model.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/chart.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/compare.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/dashboard.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/detail.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/diff.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/heatmap.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/help.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/lineage.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/mod.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/registry.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/search.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/selection.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/statusbar.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/theme.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/todo.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/tree.rs +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/setup.cfg +0 -0
- {extract_tracker-0.2.2 → extract_tracker-0.3.0}/setup.py +0 -0
|
@@ -47,3 +47,27 @@ All tools are read-only.
|
|
|
47
47
|
| `list_models` | Registered models with metadata |
|
|
48
48
|
|
|
49
49
|
Full schemas, response shapes, and error catalog live in [`DOC.md`](../DOC.md#mcp-server).
|
|
50
|
+
|
|
51
|
+
## Agent CLI surface
|
|
52
|
+
|
|
53
|
+
The same read-only operations are also exposed as JSON CLI commands for agents or hosts that cannot attach the MCP server:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
extract experiments list --store .extract --format json
|
|
57
|
+
extract runs list --limit 20 --store .extract --format json
|
|
58
|
+
extract runs get RUN_ID --store .extract --format json
|
|
59
|
+
extract runs compare RUN_A RUN_B --store .extract --format json
|
|
60
|
+
extract search --query resnet --tag production-candidate --store .extract --format json
|
|
61
|
+
extract todos list --scope-type run --scope-id RUN_ID --store .extract --format json
|
|
62
|
+
extract lineage get run RUN_ID --direction both --store .extract --format json
|
|
63
|
+
extract models list --store .extract --format json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
An installable Pi package in `pi/extract-pi-tools/` wraps these commands as native Pi tools such as `extract_list_runs`, `extract_get_run`, and `extract_compare_runs`:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pi install ./pi/extract-pi-tools # user-scoped
|
|
70
|
+
pi install -l ./pi/extract-pi-tools # project-scoped
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Reload or restart Pi after installation. These native tools are context-safe summaries, not raw dumps: list tools cap rows, `extract_get_run` omits full config unless specific `configKeys` are requested, and large raw JSON is written to a temp file path. For full configs, curves, or bulk aggregation, rerun the returned `meta.command` inside `ctx_execute` and print only the derived answer.
|
|
@@ -29,7 +29,9 @@ def _find_tui_binary() -> str | None:
|
|
|
29
29
|
return None
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def _print_merge_stats(
|
|
32
|
+
def _print_merge_stats(
|
|
33
|
+
verb: str, src: object, dst: object, stats: dict[str, int]
|
|
34
|
+
) -> None:
|
|
33
35
|
print(f"{verb} {src} → {dst}")
|
|
34
36
|
exps = stats.get("experiments", 0)
|
|
35
37
|
runs = stats.get("runs", 0)
|
|
@@ -45,29 +47,39 @@ def _print_merge_stats(verb: str, src: object, dst: object, stats: dict[str, int
|
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
def main(argv: list[str] | None = None) -> None:
|
|
48
|
-
parser = argparse.ArgumentParser(
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="extract", description="Extract experiment tracker"
|
|
52
|
+
)
|
|
49
53
|
sub = parser.add_subparsers(dest="command")
|
|
50
54
|
|
|
55
|
+
from extract.cli_query import add_query_parsers, run_query_command
|
|
56
|
+
|
|
57
|
+
add_query_parsers(sub)
|
|
58
|
+
|
|
51
59
|
# --- tui ---
|
|
52
60
|
tui_parser = sub.add_parser("tui", help="Launch the TUI explorer")
|
|
53
|
-
tui_parser.add_argument(
|
|
61
|
+
tui_parser.add_argument(
|
|
62
|
+
"--store", default=".extract", help="Path to .extract/ directory"
|
|
63
|
+
)
|
|
54
64
|
|
|
55
65
|
# --- init ---
|
|
56
66
|
init_parser = sub.add_parser(
|
|
57
67
|
"init", help="Bootstrap a .extract/ store with a hierarchy"
|
|
58
68
|
)
|
|
59
69
|
init_parser.add_argument(
|
|
60
|
-
"path",
|
|
61
|
-
|
|
70
|
+
"path",
|
|
71
|
+
nargs="?",
|
|
72
|
+
default=".extract",
|
|
73
|
+
help="Path to create the store at (default: .extract)",
|
|
62
74
|
)
|
|
63
75
|
init_parser.add_argument(
|
|
64
|
-
"--hierarchy",
|
|
76
|
+
"--hierarchy",
|
|
77
|
+
default=None,
|
|
65
78
|
help="Skip the interactive picker; use this hierarchy "
|
|
66
|
-
|
|
79
|
+
"(e.g. 'benchmark > model > variant')",
|
|
67
80
|
)
|
|
68
81
|
init_parser.add_argument(
|
|
69
|
-
"--no-gitignore", action="store_true",
|
|
70
|
-
help="Do not add .extract/ to .gitignore"
|
|
82
|
+
"--no-gitignore", action="store_true", help="Do not add .extract/ to .gitignore"
|
|
71
83
|
)
|
|
72
84
|
|
|
73
85
|
# --- sync ---
|
|
@@ -84,14 +96,24 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
84
96
|
|
|
85
97
|
export_p = sync_sub.add_parser("export", help="Archive .extract/ to tar.gz")
|
|
86
98
|
export_p.add_argument("output", help="Output archive path, e.g. experiments.tar.gz")
|
|
87
|
-
export_p.add_argument(
|
|
99
|
+
export_p.add_argument(
|
|
100
|
+
"--root", default=".extract", help="Local .extract/ directory"
|
|
101
|
+
)
|
|
88
102
|
|
|
89
|
-
import_p = sync_sub.add_parser(
|
|
103
|
+
import_p = sync_sub.add_parser(
|
|
104
|
+
"import", help="Import tar.gz archive into .extract/"
|
|
105
|
+
)
|
|
90
106
|
import_p.add_argument("archive", help="Archive path to import")
|
|
91
|
-
import_p.add_argument(
|
|
107
|
+
import_p.add_argument(
|
|
108
|
+
"--root", default=".extract", help="Target .extract/ directory"
|
|
109
|
+
)
|
|
92
110
|
|
|
93
111
|
args = parser.parse_args(argv)
|
|
94
112
|
|
|
113
|
+
query_exit = run_query_command(args)
|
|
114
|
+
if query_exit is not None:
|
|
115
|
+
sys.exit(query_exit)
|
|
116
|
+
|
|
95
117
|
if args.command == "tui":
|
|
96
118
|
binary = _find_tui_binary()
|
|
97
119
|
if binary is None:
|
|
@@ -105,7 +127,10 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
105
127
|
os.execvp(binary, [binary, "--store", args.store])
|
|
106
128
|
|
|
107
129
|
elif args.command == "init":
|
|
108
|
-
from extract import
|
|
130
|
+
from extract import (
|
|
131
|
+
init,
|
|
132
|
+
) # lazy import — pulls in rich/questionary only when needed
|
|
133
|
+
|
|
109
134
|
sys.exit(init.run(args))
|
|
110
135
|
|
|
111
136
|
elif args.command == "sync":
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""JSON CLI adapter for Extract read-only query tools.
|
|
2
|
+
|
|
3
|
+
This module reuses the existing MCP tool implementations so the command-line
|
|
4
|
+
surface and MCP surface share one result contract while the query core is
|
|
5
|
+
factored out in a later cleanup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from extract.store import Store
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _add_common(parser: argparse.ArgumentParser) -> None:
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--store", default=".extract", help="Path to .extract/ directory"
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--format",
|
|
25
|
+
choices=("json",),
|
|
26
|
+
default="json",
|
|
27
|
+
help="Output format (default: json)",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_query_parsers(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
32
|
+
"""Register read-only query subcommands on the top-level CLI parser."""
|
|
33
|
+
experiments = sub.add_parser("experiments", help="Read experiments from a store")
|
|
34
|
+
exp_sub = experiments.add_subparsers(dest="query_action")
|
|
35
|
+
exp_list = exp_sub.add_parser("list", help="List experiments")
|
|
36
|
+
_add_common(exp_list)
|
|
37
|
+
exp_list.add_argument("--prefix", default="", help="Experiment path prefix")
|
|
38
|
+
exp_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
|
|
39
|
+
exp_list.add_argument("--include-archived", action="store_true")
|
|
40
|
+
exp_list.set_defaults(_extract_query="list_experiments")
|
|
41
|
+
|
|
42
|
+
runs = sub.add_parser("runs", help="Read runs from a store")
|
|
43
|
+
runs_sub = runs.add_subparsers(dest="query_action")
|
|
44
|
+
runs_list = runs_sub.add_parser("list", help="List runs")
|
|
45
|
+
_add_common(runs_list)
|
|
46
|
+
runs_list.add_argument(
|
|
47
|
+
"--experiment-id", default=None, help="Scope to one experiment id"
|
|
48
|
+
)
|
|
49
|
+
runs_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
|
|
50
|
+
runs_list.add_argument("--include-archived", action="store_true")
|
|
51
|
+
runs_list.set_defaults(_extract_query="list_runs")
|
|
52
|
+
|
|
53
|
+
runs_get = runs_sub.add_parser("get", help="Get full run detail")
|
|
54
|
+
_add_common(runs_get)
|
|
55
|
+
runs_get.add_argument("run_id", help="Run ULID")
|
|
56
|
+
runs_get.set_defaults(_extract_query="get_run")
|
|
57
|
+
|
|
58
|
+
runs_compare = runs_sub.add_parser("compare", help="Compare 2-10 runs")
|
|
59
|
+
_add_common(runs_compare)
|
|
60
|
+
runs_compare.add_argument("run_ids", nargs="+", help="Run ULIDs")
|
|
61
|
+
runs_compare.add_argument("--include-curves", action="store_true")
|
|
62
|
+
runs_compare.set_defaults(_extract_query="compare_runs")
|
|
63
|
+
|
|
64
|
+
search = sub.add_parser("search", help="Search runs")
|
|
65
|
+
_add_common(search)
|
|
66
|
+
search.add_argument("--query", default="", help="Case-insensitive text search")
|
|
67
|
+
search.add_argument("--tag", default=None, help="Require tag")
|
|
68
|
+
search.add_argument("--status", default=None, help="Require status")
|
|
69
|
+
search.add_argument(
|
|
70
|
+
"--experiment-prefix", default=None, help="Experiment path prefix"
|
|
71
|
+
)
|
|
72
|
+
search.add_argument(
|
|
73
|
+
"--started-after", default=None, help="ISO timestamp lower bound"
|
|
74
|
+
)
|
|
75
|
+
search.add_argument(
|
|
76
|
+
"--started-before", default=None, help="ISO timestamp upper bound"
|
|
77
|
+
)
|
|
78
|
+
search.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
|
|
79
|
+
search.add_argument("--include-archived", action="store_true")
|
|
80
|
+
search.set_defaults(_extract_query="search")
|
|
81
|
+
|
|
82
|
+
todos = sub.add_parser("todos", help="Read TODOs from a store")
|
|
83
|
+
todos_sub = todos.add_subparsers(dest="query_action")
|
|
84
|
+
todos_list = todos_sub.add_parser("list", help="List TODOs")
|
|
85
|
+
_add_common(todos_list)
|
|
86
|
+
todos_list.add_argument(
|
|
87
|
+
"--scope-type",
|
|
88
|
+
default="global",
|
|
89
|
+
choices=("global", "experiment", "run"),
|
|
90
|
+
help="TODO scope",
|
|
91
|
+
)
|
|
92
|
+
todos_list.add_argument(
|
|
93
|
+
"--scope-id", default=None, help="Required for experiment/run scope"
|
|
94
|
+
)
|
|
95
|
+
todos_list.add_argument("--include-done", action="store_true")
|
|
96
|
+
todos_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
|
|
97
|
+
todos_list.set_defaults(_extract_query="list_todos")
|
|
98
|
+
|
|
99
|
+
lineage = sub.add_parser("lineage", help="Read lineage graph from a store")
|
|
100
|
+
lineage_sub = lineage.add_subparsers(dest="query_action")
|
|
101
|
+
lineage_get = lineage_sub.add_parser("get", help="Walk lineage DAG")
|
|
102
|
+
_add_common(lineage_get)
|
|
103
|
+
lineage_get.add_argument("node_type", choices=("experiment", "run", "model"))
|
|
104
|
+
lineage_get.add_argument("node_id")
|
|
105
|
+
lineage_get.add_argument(
|
|
106
|
+
"--direction",
|
|
107
|
+
default="both",
|
|
108
|
+
choices=("ancestors", "descendants", "both"),
|
|
109
|
+
)
|
|
110
|
+
lineage_get.add_argument("--depth", type=int, default=2)
|
|
111
|
+
lineage_get.set_defaults(_extract_query="get_lineage")
|
|
112
|
+
|
|
113
|
+
models = sub.add_parser("models", help="Read model registry from a store")
|
|
114
|
+
models_sub = models.add_subparsers(dest="query_action")
|
|
115
|
+
models_list = models_sub.add_parser("list", help="List registered models")
|
|
116
|
+
_add_common(models_list)
|
|
117
|
+
models_list.add_argument("--name-prefix", default="", help="Model name prefix")
|
|
118
|
+
models_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
|
|
119
|
+
models_list.set_defaults(_extract_query="list_models")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _json_error(code: str, message: str) -> str:
|
|
123
|
+
return json.dumps(
|
|
124
|
+
{"error": {"code": code, "message": message}}, indent=2, sort_keys=True
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_filters(args: argparse.Namespace) -> dict[str, str]:
|
|
129
|
+
filters: dict[str, str] = {}
|
|
130
|
+
for attr, key in (
|
|
131
|
+
("tag", "tag"),
|
|
132
|
+
("status", "status"),
|
|
133
|
+
("experiment_prefix", "experiment_prefix"),
|
|
134
|
+
("started_after", "started_after"),
|
|
135
|
+
("started_before", "started_before"),
|
|
136
|
+
):
|
|
137
|
+
value = getattr(args, attr, None)
|
|
138
|
+
if value is not None:
|
|
139
|
+
filters[key] = value
|
|
140
|
+
return filters
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _call_query(args: argparse.Namespace) -> Any:
|
|
144
|
+
import extract.mcp as mcp_mod
|
|
145
|
+
|
|
146
|
+
action = args._extract_query
|
|
147
|
+
if action == "list_experiments":
|
|
148
|
+
return mcp_mod.list_experiments(
|
|
149
|
+
prefix=args.prefix,
|
|
150
|
+
limit=args.limit,
|
|
151
|
+
include_archived=args.include_archived,
|
|
152
|
+
)
|
|
153
|
+
if action == "list_runs":
|
|
154
|
+
return mcp_mod.list_runs(
|
|
155
|
+
experiment_id=args.experiment_id,
|
|
156
|
+
limit=args.limit,
|
|
157
|
+
include_archived=args.include_archived,
|
|
158
|
+
)
|
|
159
|
+
if action == "get_run":
|
|
160
|
+
return mcp_mod.get_run(args.run_id)
|
|
161
|
+
if action == "compare_runs":
|
|
162
|
+
return mcp_mod.compare_runs(args.run_ids, include_curves=args.include_curves)
|
|
163
|
+
if action == "search":
|
|
164
|
+
return mcp_mod.search(
|
|
165
|
+
query=args.query,
|
|
166
|
+
filters=_build_filters(args),
|
|
167
|
+
limit=args.limit,
|
|
168
|
+
include_archived=args.include_archived,
|
|
169
|
+
)
|
|
170
|
+
if action == "list_todos":
|
|
171
|
+
return mcp_mod.list_todos(
|
|
172
|
+
scope_type=args.scope_type,
|
|
173
|
+
scope_id=args.scope_id,
|
|
174
|
+
include_done=args.include_done,
|
|
175
|
+
limit=args.limit,
|
|
176
|
+
)
|
|
177
|
+
if action == "get_lineage":
|
|
178
|
+
return mcp_mod.get_lineage(
|
|
179
|
+
node_type=args.node_type,
|
|
180
|
+
node_id=args.node_id,
|
|
181
|
+
direction=args.direction,
|
|
182
|
+
depth=args.depth,
|
|
183
|
+
)
|
|
184
|
+
if action == "list_models":
|
|
185
|
+
return mcp_mod.list_models(name_prefix=args.name_prefix, limit=args.limit)
|
|
186
|
+
raise RuntimeError(f"unknown query action: {action}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_query_command(args: argparse.Namespace) -> int | None:
|
|
190
|
+
"""Run a query command if args selects one; else return None."""
|
|
191
|
+
if not hasattr(args, "_extract_query"):
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
store_path = Path(args.store)
|
|
195
|
+
if not store_path.exists():
|
|
196
|
+
print(
|
|
197
|
+
_json_error(
|
|
198
|
+
"store_not_found",
|
|
199
|
+
f"store not found: {store_path} — run training with extract-tracker first, or pass --store",
|
|
200
|
+
),
|
|
201
|
+
file=sys.stdout,
|
|
202
|
+
)
|
|
203
|
+
return 2
|
|
204
|
+
|
|
205
|
+
import extract.mcp as mcp_mod
|
|
206
|
+
|
|
207
|
+
store: Store | None = None
|
|
208
|
+
try:
|
|
209
|
+
store = Store(store_path)
|
|
210
|
+
mcp_mod._store = store
|
|
211
|
+
result = _call_query(args)
|
|
212
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
213
|
+
return 0
|
|
214
|
+
except ValueError as exc:
|
|
215
|
+
print(_json_error("invalid_request", str(exc)), file=sys.stdout)
|
|
216
|
+
return 1
|
|
217
|
+
except Exception as exc: # pragma: no cover - defensive CLI boundary
|
|
218
|
+
print(_json_error("internal_error", str(exc)), file=sys.stdout)
|
|
219
|
+
return 70
|
|
220
|
+
finally:
|
|
221
|
+
mcp_mod._store = None
|
|
222
|
+
if store is not None:
|
|
223
|
+
store.close()
|
|
@@ -12,9 +12,9 @@ import json
|
|
|
12
12
|
import sys
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import tomli
|
|
17
16
|
from extract.store import Store
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
18
|
|
|
19
19
|
# Module-level state. Set by main() at startup; monkey-patched by tests.
|
|
20
20
|
_store: Store | None = None
|
|
@@ -31,8 +31,17 @@ def _tool(fn):
|
|
|
31
31
|
# ----------------------------------------------------------------------
|
|
32
32
|
|
|
33
33
|
_MIN_METRIC_PATTERNS = (
|
|
34
|
-
"loss",
|
|
35
|
-
"
|
|
34
|
+
"loss",
|
|
35
|
+
"error",
|
|
36
|
+
"perplexity",
|
|
37
|
+
"mse",
|
|
38
|
+
"mae",
|
|
39
|
+
"rmse",
|
|
40
|
+
"nll",
|
|
41
|
+
"cer",
|
|
42
|
+
"wer",
|
|
43
|
+
"fid",
|
|
44
|
+
"divergence",
|
|
36
45
|
)
|
|
37
46
|
|
|
38
47
|
|
|
@@ -70,8 +79,18 @@ def _flatten_config(config: dict, prefix: str = "") -> dict:
|
|
|
70
79
|
return result
|
|
71
80
|
|
|
72
81
|
|
|
73
|
-
def _metric_direction(
|
|
74
|
-
|
|
82
|
+
def _metric_direction(
|
|
83
|
+
name: str,
|
|
84
|
+
overrides: tuple[set[str], set[str]] | None = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Return 'min' or 'max' using config overrides, then heuristics."""
|
|
87
|
+
if overrides is not None:
|
|
88
|
+
minimize, maximize = overrides
|
|
89
|
+
if name in minimize:
|
|
90
|
+
return "min"
|
|
91
|
+
if name in maximize:
|
|
92
|
+
return "max"
|
|
93
|
+
|
|
75
94
|
lowered = name.lower()
|
|
76
95
|
for pat in _MIN_METRIC_PATTERNS:
|
|
77
96
|
if pat in lowered:
|
|
@@ -79,6 +98,35 @@ def _metric_direction(name: str) -> str:
|
|
|
79
98
|
return "max"
|
|
80
99
|
|
|
81
100
|
|
|
101
|
+
def _metric_direction_overrides(store: Store) -> tuple[set[str], set[str]]:
|
|
102
|
+
"""Read [metrics] minimize/maximize overrides from config.toml."""
|
|
103
|
+
config_path = store.root / "config.toml"
|
|
104
|
+
if not config_path.exists():
|
|
105
|
+
return set(), set()
|
|
106
|
+
|
|
107
|
+
with config_path.open("rb") as f:
|
|
108
|
+
data = tomli.load(f)
|
|
109
|
+
|
|
110
|
+
metrics = data.get("metrics", {})
|
|
111
|
+
if not isinstance(metrics, dict):
|
|
112
|
+
return set(), set()
|
|
113
|
+
|
|
114
|
+
minimize = metrics.get("minimize", [])
|
|
115
|
+
maximize = metrics.get("maximize", [])
|
|
116
|
+
return (
|
|
117
|
+
(
|
|
118
|
+
{m for m in minimize if isinstance(m, str)}
|
|
119
|
+
if isinstance(minimize, list)
|
|
120
|
+
else set()
|
|
121
|
+
),
|
|
122
|
+
(
|
|
123
|
+
{m for m in maximize if isinstance(m, str)}
|
|
124
|
+
if isinstance(maximize, list)
|
|
125
|
+
else set()
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
82
130
|
def _config_diffs(runs_configs: list[tuple[str, dict]]) -> dict:
|
|
83
131
|
"""Return {flat_key: {run_id: value}} for keys that differ across runs.
|
|
84
132
|
|
|
@@ -96,8 +144,9 @@ def _config_diffs(runs_configs: list[tuple[str, dict]]) -> dict:
|
|
|
96
144
|
result: dict = {}
|
|
97
145
|
for key in all_keys:
|
|
98
146
|
values = [(rid, flat.get(key, _MISSING)) for rid, flat in flattened]
|
|
99
|
-
distinct = {
|
|
100
|
-
|
|
147
|
+
distinct = {
|
|
148
|
+
id(v) if v is _MISSING else (type(v).__name__, repr(v)) for _, v in values
|
|
149
|
+
}
|
|
101
150
|
if len(distinct) > 1:
|
|
102
151
|
result[key] = {rid: v for rid, v in values if v is not _MISSING}
|
|
103
152
|
return result
|
|
@@ -193,14 +242,16 @@ def list_experiments(
|
|
|
193
242
|
f"SELECT COUNT(*) FROM runs WHERE experiment_id = ?{run_filter}",
|
|
194
243
|
(row["id"],),
|
|
195
244
|
).fetchone()
|
|
196
|
-
items.append(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
245
|
+
items.append(
|
|
246
|
+
{
|
|
247
|
+
"id": row["id"],
|
|
248
|
+
"path": row["path"],
|
|
249
|
+
"name": row["name"],
|
|
250
|
+
"node_type": row["node_type"],
|
|
251
|
+
"parent_id": row["parent_id"],
|
|
252
|
+
"n_runs": n_runs_row[0],
|
|
253
|
+
}
|
|
254
|
+
)
|
|
204
255
|
|
|
205
256
|
return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
|
|
206
257
|
|
|
@@ -257,23 +308,25 @@ def list_runs(
|
|
|
257
308
|
for row in rows:
|
|
258
309
|
config_dict = json.loads(row["config"]) if row["config"] else {}
|
|
259
310
|
top_keys = list(config_dict.keys())
|
|
260
|
-
items.append(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
"
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
311
|
+
items.append(
|
|
312
|
+
{
|
|
313
|
+
"id": row["id"],
|
|
314
|
+
"label": _label(row["experiment_path"], row["name"], row["id"]),
|
|
315
|
+
"experiment_id": row["experiment_id"],
|
|
316
|
+
"experiment_path": row["experiment_path"],
|
|
317
|
+
"name": row["name"],
|
|
318
|
+
"status": row["status"],
|
|
319
|
+
"started_at": row["started_at"],
|
|
320
|
+
"ended_at": row["ended_at"],
|
|
321
|
+
"tags": json.loads(row["tags"]) if row["tags"] else [],
|
|
322
|
+
"git_sha": row["git_sha"],
|
|
323
|
+
"hostname": row["hostname"],
|
|
324
|
+
"config_summary": {
|
|
325
|
+
"n_keys": len(top_keys),
|
|
326
|
+
"top_level_keys": top_keys,
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
)
|
|
277
330
|
|
|
278
331
|
return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
|
|
279
332
|
|
|
@@ -308,16 +361,18 @@ def list_models(name_prefix: str = "", limit: int = 50) -> dict:
|
|
|
308
361
|
|
|
309
362
|
items: list[dict] = []
|
|
310
363
|
for row in rows:
|
|
311
|
-
items.append(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
364
|
+
items.append(
|
|
365
|
+
{
|
|
366
|
+
"id": row["id"],
|
|
367
|
+
"name": row["name"],
|
|
368
|
+
"version": row["version"],
|
|
369
|
+
"run_id": row["run_id"],
|
|
370
|
+
"framework": row["framework"],
|
|
371
|
+
"artifact_path": row["artifact_path"],
|
|
372
|
+
"metadata": json.loads(row["metadata"]) if row["metadata"] else None,
|
|
373
|
+
"created_at": row["created_at"],
|
|
374
|
+
}
|
|
375
|
+
)
|
|
321
376
|
|
|
322
377
|
return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
|
|
323
378
|
|
|
@@ -429,14 +484,16 @@ def get_run(run_id: str) -> dict:
|
|
|
429
484
|
).fetchall()
|
|
430
485
|
artifacts: list[dict] = []
|
|
431
486
|
for a in art_rows:
|
|
432
|
-
artifacts.append(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
487
|
+
artifacts.append(
|
|
488
|
+
{
|
|
489
|
+
"name": a["name"],
|
|
490
|
+
"kind": a["kind"],
|
|
491
|
+
"step": a["step"],
|
|
492
|
+
"rel_path": a["rel_path"],
|
|
493
|
+
"shape": json.loads(a["shape"]) if a["shape"] else None,
|
|
494
|
+
"dtype": a["dtype"],
|
|
495
|
+
}
|
|
496
|
+
)
|
|
440
497
|
|
|
441
498
|
todo_rows = _store._conn.execute(
|
|
442
499
|
"SELECT id, content, priority, done, created_at, completed_at "
|
|
@@ -470,7 +527,11 @@ def get_run(run_id: str) -> dict:
|
|
|
470
527
|
|
|
471
528
|
_VALID_STATUS = {"running", "completed", "failed", "archived"}
|
|
472
529
|
_VALID_FILTERS = {
|
|
473
|
-
"tag",
|
|
530
|
+
"tag",
|
|
531
|
+
"status",
|
|
532
|
+
"experiment_prefix",
|
|
533
|
+
"started_after",
|
|
534
|
+
"started_before",
|
|
474
535
|
}
|
|
475
536
|
|
|
476
537
|
|
|
@@ -568,23 +629,25 @@ def search(
|
|
|
568
629
|
for row in rows:
|
|
569
630
|
config_dict = json.loads(row["config"]) if row["config"] else {}
|
|
570
631
|
top_keys = list(config_dict.keys())
|
|
571
|
-
items.append(
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
632
|
+
items.append(
|
|
633
|
+
{
|
|
634
|
+
"id": row["id"],
|
|
635
|
+
"label": _label(row["experiment_path"], row["name"], row["id"]),
|
|
636
|
+
"experiment_id": row["experiment_id"],
|
|
637
|
+
"experiment_path": row["experiment_path"],
|
|
638
|
+
"name": row["name"],
|
|
639
|
+
"status": row["status"],
|
|
640
|
+
"started_at": row["started_at"],
|
|
641
|
+
"ended_at": row["ended_at"],
|
|
642
|
+
"tags": json.loads(row["tags"]) if row["tags"] else [],
|
|
643
|
+
"git_sha": row["git_sha"],
|
|
644
|
+
"hostname": row["hostname"],
|
|
645
|
+
"config_summary": {
|
|
646
|
+
"n_keys": len(top_keys),
|
|
647
|
+
"top_level_keys": top_keys,
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
)
|
|
588
651
|
|
|
589
652
|
return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
|
|
590
653
|
|
|
@@ -617,7 +680,9 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
|
|
|
617
680
|
}
|
|
618
681
|
"""
|
|
619
682
|
if len(run_ids) < 2:
|
|
620
|
-
raise ValueError(
|
|
683
|
+
raise ValueError(
|
|
684
|
+
f"compare_runs requires at least 2 run_ids (got {len(run_ids)})"
|
|
685
|
+
)
|
|
621
686
|
if len(run_ids) > 10:
|
|
622
687
|
raise ValueError(
|
|
623
688
|
f"compare_runs supports at most 10 runs per call (got {len(run_ids)})"
|
|
@@ -627,7 +692,9 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
|
|
|
627
692
|
runs_out: list[dict] = []
|
|
628
693
|
configs: list[tuple[str, dict]] = []
|
|
629
694
|
metric_values: dict[str, dict[str, float]] = {} # name -> {run_id: final_val}
|
|
630
|
-
curves_out: dict[str, dict[str, list[list]]] =
|
|
695
|
+
curves_out: dict[str, dict[str, list[list]]] = (
|
|
696
|
+
{}
|
|
697
|
+
) # name -> {run_id: [[step, value], ...]}
|
|
631
698
|
|
|
632
699
|
with _store.lock:
|
|
633
700
|
for rid in run_ids:
|
|
@@ -640,12 +707,14 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
|
|
|
640
707
|
if row is None:
|
|
641
708
|
raise ValueError(f"Run not found: {rid!r}")
|
|
642
709
|
|
|
643
|
-
runs_out.append(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
710
|
+
runs_out.append(
|
|
711
|
+
{
|
|
712
|
+
"id": row["id"],
|
|
713
|
+
"label": _label(row["experiment_path"], row["name"], row["id"]),
|
|
714
|
+
"experiment_path": row["experiment_path"],
|
|
715
|
+
"status": row["status"],
|
|
716
|
+
}
|
|
717
|
+
)
|
|
649
718
|
cfg = json.loads(row["config"]) if row["config"] else {}
|
|
650
719
|
configs.append((rid, cfg))
|
|
651
720
|
|
|
@@ -671,13 +740,15 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
|
|
|
671
740
|
)
|
|
672
741
|
|
|
673
742
|
# Build the metrics dict.
|
|
743
|
+
direction_overrides = _metric_direction_overrides(_store)
|
|
674
744
|
metrics_out: dict[str, dict] = {}
|
|
675
745
|
for name, vals in metric_values.items():
|
|
676
|
-
direction = _metric_direction(name)
|
|
677
|
-
reverse =
|
|
678
|
-
ranking = [
|
|
679
|
-
|
|
680
|
-
|
|
746
|
+
direction = _metric_direction(name, direction_overrides)
|
|
747
|
+
reverse = direction == "max"
|
|
748
|
+
ranking = [
|
|
749
|
+
rid
|
|
750
|
+
for rid, _ in sorted(vals.items(), key=lambda kv: kv[1], reverse=reverse)
|
|
751
|
+
]
|
|
681
752
|
metrics_out[name] = {
|
|
682
753
|
"direction": direction,
|
|
683
754
|
"values": vals,
|
|
@@ -767,7 +838,9 @@ def get_lineage(
|
|
|
767
838
|
with _store.lock:
|
|
768
839
|
root_label = _lookup_node_label(_store._conn, node_type, node_id)
|
|
769
840
|
if root_label is None:
|
|
770
|
-
pretty = {"run": "Run", "experiment": "Experiment", "model": "Model"}[
|
|
841
|
+
pretty = {"run": "Run", "experiment": "Experiment", "model": "Model"}[
|
|
842
|
+
node_type
|
|
843
|
+
]
|
|
771
844
|
raise ValueError(f"{pretty} not found: {node_id!r}")
|
|
772
845
|
|
|
773
846
|
visited: set[tuple[str, str]] = {(node_type, node_id)}
|
|
@@ -776,7 +849,7 @@ def get_lineage(
|
|
|
776
849
|
|
|
777
850
|
for _hop in range(depth):
|
|
778
851
|
next_frontier: list[tuple[str, str]] = []
|
|
779
|
-
for
|
|
852
|
+
for nt, nid in frontier:
|
|
780
853
|
# Descendants: I am the parent.
|
|
781
854
|
if direction in ("descendants", "both"):
|
|
782
855
|
rows = _store._conn.execute(
|
|
@@ -811,14 +884,19 @@ def get_lineage(
|
|
|
811
884
|
seen_edges: set[tuple] = set()
|
|
812
885
|
unique_edges: list[dict] = []
|
|
813
886
|
for e in edges_out:
|
|
814
|
-
key = (
|
|
815
|
-
|
|
887
|
+
key = (
|
|
888
|
+
e["parent_type"],
|
|
889
|
+
e["parent_id"],
|
|
890
|
+
e["child_type"],
|
|
891
|
+
e["child_id"],
|
|
892
|
+
e["relation"],
|
|
893
|
+
)
|
|
816
894
|
if key not in seen_edges:
|
|
817
895
|
seen_edges.add(key)
|
|
818
896
|
unique_edges.append(e)
|
|
819
897
|
|
|
820
898
|
nodes_out: list[dict] = []
|
|
821
|
-
for
|
|
899
|
+
for nt, nid in visited:
|
|
822
900
|
if (nt, nid) == (node_type, node_id):
|
|
823
901
|
continue # root is emitted separately
|
|
824
902
|
label = _lookup_node_label(_store._conn, nt, nid)
|
|
@@ -306,6 +306,7 @@ pub struct RunBrowserState {
|
|
|
306
306
|
pub filtered: Vec<usize>,
|
|
307
307
|
pub cursor: usize,
|
|
308
308
|
pub search_query: Option<String>,
|
|
309
|
+
pub rename_buffer: Option<String>,
|
|
309
310
|
pub scroll_offset: usize,
|
|
310
311
|
}
|
|
311
312
|
|
|
@@ -318,6 +319,7 @@ impl RunBrowserState {
|
|
|
318
319
|
filtered,
|
|
319
320
|
cursor: 0,
|
|
320
321
|
search_query: None,
|
|
322
|
+
rename_buffer: None,
|
|
321
323
|
scroll_offset: 0,
|
|
322
324
|
}
|
|
323
325
|
}
|
|
@@ -854,6 +854,19 @@ impl Db {
|
|
|
854
854
|
Ok(())
|
|
855
855
|
}
|
|
856
856
|
|
|
857
|
+
/// Rename a run. Empty string clears the name. Opens a writable connection.
|
|
858
|
+
pub fn rename_run(db_path: &Path, id: &str, name: &str) -> Result<()> {
|
|
859
|
+
let conn = Connection::open(db_path)?;
|
|
860
|
+
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
|
861
|
+
let trimmed = name.trim();
|
|
862
|
+
let value: Option<&str> = if trimmed.is_empty() { None } else { Some(trimmed) };
|
|
863
|
+
conn.execute(
|
|
864
|
+
"UPDATE runs SET name = ? WHERE id = ?",
|
|
865
|
+
params![value, id],
|
|
866
|
+
)?;
|
|
867
|
+
Ok(())
|
|
868
|
+
}
|
|
869
|
+
|
|
857
870
|
/// Set status on a single run or experiment. Opens a writable connection.
|
|
858
871
|
pub fn set_status(db_path: &Path, table: &str, id: &str, status: &str) -> Result<()> {
|
|
859
872
|
assert!(table == "runs" || table == "experiments");
|
|
@@ -1314,6 +1327,18 @@ mod tests {
|
|
|
1314
1327
|
assert_eq!(run.status, "failed");
|
|
1315
1328
|
}
|
|
1316
1329
|
|
|
1330
|
+
#[test]
|
|
1331
|
+
fn test_rename_run() {
|
|
1332
|
+
let tdb = test_db_with_path();
|
|
1333
|
+
Db::rename_run(&tdb.path, "r1", "renamed").unwrap();
|
|
1334
|
+
let run = tdb.db.get_run("r1").unwrap().unwrap();
|
|
1335
|
+
assert_eq!(run.name.as_deref(), Some("renamed"));
|
|
1336
|
+
|
|
1337
|
+
Db::rename_run(&tdb.path, "r1", " ").unwrap();
|
|
1338
|
+
let run = tdb.db.get_run("r1").unwrap().unwrap();
|
|
1339
|
+
assert!(run.name.is_none());
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1317
1342
|
#[test]
|
|
1318
1343
|
fn test_archive_experiment() {
|
|
1319
1344
|
let tdb = test_db_with_path();
|
|
@@ -82,7 +82,14 @@ impl AppLayout {
|
|
|
82
82
|
// focused panel's input handler see every keystroke unmodified.
|
|
83
83
|
let in_text_input = state.tag_picker.is_some()
|
|
84
84
|
|| state.note_input.is_some()
|
|
85
|
-
|| state.todo_input.is_some()
|
|
85
|
+
|| state.todo_input.is_some()
|
|
86
|
+
|| state
|
|
87
|
+
.run_picker
|
|
88
|
+
.as_ref()
|
|
89
|
+
.is_some_and(|picker| picker.search_query.is_some())
|
|
90
|
+
|| state.run_browser.as_ref().is_some_and(|browser| {
|
|
91
|
+
browser.search_query.is_some() || browser.rename_buffer.is_some()
|
|
92
|
+
});
|
|
86
93
|
|
|
87
94
|
if !in_text_input {
|
|
88
95
|
// Global keys: gg/G, ?, work in all views
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
use
|
|
1
|
+
use chrono::{DateTime, Local};
|
|
2
|
+
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
2
3
|
use ratatui::layout::Rect;
|
|
3
4
|
use ratatui::style::{Modifier, Style};
|
|
4
5
|
use ratatui::symbols::border;
|
|
@@ -216,6 +217,70 @@ impl PopupRenderer {
|
|
|
216
217
|
};
|
|
217
218
|
|
|
218
219
|
let is_searching = browser.search_query.is_some();
|
|
220
|
+
let is_renaming = browser.rename_buffer.is_some();
|
|
221
|
+
|
|
222
|
+
// Rename mode
|
|
223
|
+
if is_renaming {
|
|
224
|
+
match key.code {
|
|
225
|
+
KeyCode::Esc => {
|
|
226
|
+
browser.rename_buffer = None;
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
KeyCode::Enter => {
|
|
230
|
+
if let Some(&run_idx) = browser.filtered.get(browser.cursor) {
|
|
231
|
+
if let Some(run) = browser.runs.get(run_idx) {
|
|
232
|
+
let run_id = run.id.clone();
|
|
233
|
+
let new_name = browser.rename_buffer.take().unwrap_or_default();
|
|
234
|
+
let db_path = state.store_root.join("extract.db");
|
|
235
|
+
match crate::db::Db::rename_run(&db_path, &run_id, &new_name) {
|
|
236
|
+
Ok(()) => {
|
|
237
|
+
let trimmed = new_name.trim();
|
|
238
|
+
let value = if trimmed.is_empty() {
|
|
239
|
+
None
|
|
240
|
+
} else {
|
|
241
|
+
Some(trimmed.to_string())
|
|
242
|
+
};
|
|
243
|
+
if let Some(run) = browser.runs.get_mut(run_idx) {
|
|
244
|
+
run.name = value.clone();
|
|
245
|
+
}
|
|
246
|
+
if let Some(state_idx) = state.runs.iter().position(|r| r.id == run_id) {
|
|
247
|
+
state.runs[state_idx].name = value;
|
|
248
|
+
}
|
|
249
|
+
let _ = state.refresh_selection_summary();
|
|
250
|
+
state.notify(crate::app::NotifyLevel::Success, "Run renamed");
|
|
251
|
+
}
|
|
252
|
+
Err(err) => {
|
|
253
|
+
state.notify(
|
|
254
|
+
crate::app::NotifyLevel::Error,
|
|
255
|
+
format!("Rename failed: {err}"),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
browser.rename_buffer = None;
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
browser.rename_buffer = None;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
KeyCode::Backspace => {
|
|
268
|
+
if let Some(ref mut name) = browser.rename_buffer {
|
|
269
|
+
name.pop();
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
KeyCode::Char(c) => {
|
|
274
|
+
if accepts_text_modifiers(key) {
|
|
275
|
+
if let Some(ref mut name) = browser.rename_buffer {
|
|
276
|
+
name.push(c);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
_ => return false,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
219
284
|
|
|
220
285
|
// Search mode
|
|
221
286
|
if is_searching {
|
|
@@ -290,6 +355,15 @@ impl PopupRenderer {
|
|
|
290
355
|
return false;
|
|
291
356
|
}
|
|
292
357
|
|
|
358
|
+
if is_rename_key(key) {
|
|
359
|
+
if let Some(&run_idx) = browser.filtered.get(browser.cursor) {
|
|
360
|
+
if let Some(run) = browser.runs.get(run_idx) {
|
|
361
|
+
browser.rename_buffer = Some(run.name.clone().unwrap_or_default());
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
293
367
|
if keys::matches(key, keys::SELECT) {
|
|
294
368
|
if let Some(&filtered_idx) = browser.filtered.get(browser.cursor) {
|
|
295
369
|
if let Some(run) = browser.runs.get(filtered_idx) {
|
|
@@ -499,6 +573,7 @@ impl PopupRenderer {
|
|
|
499
573
|
browser: &mut RunBrowserState,
|
|
500
574
|
) {
|
|
501
575
|
let is_searching = browser.search_query.is_some();
|
|
576
|
+
let is_renaming = browser.rename_buffer.is_some();
|
|
502
577
|
let width = POPUP_WIDTH.min(area.width.saturating_sub(4));
|
|
503
578
|
let height = POPUP_HEIGHT.min(area.height.saturating_sub(4));
|
|
504
579
|
let popup_area = centered_rect(width, height, area);
|
|
@@ -508,12 +583,16 @@ impl PopupRenderer {
|
|
|
508
583
|
let title = format!(" {} — runs ", browser.experiment_name);
|
|
509
584
|
let footer_spans = if is_searching {
|
|
510
585
|
search_footer_spans(&self.theme)
|
|
586
|
+
} else if is_renaming {
|
|
587
|
+
rename_footer_spans(&self.theme)
|
|
511
588
|
} else {
|
|
512
589
|
vec![
|
|
513
590
|
Span::styled("j/k", Style::default().fg(self.theme.accent)),
|
|
514
591
|
Span::styled(" nav ", Style::default().fg(self.theme.accent_dim)),
|
|
515
592
|
Span::styled("Enter", Style::default().fg(self.theme.accent)),
|
|
516
593
|
Span::styled(" select ", Style::default().fg(self.theme.accent_dim)),
|
|
594
|
+
Span::styled("R", Style::default().fg(self.theme.accent)),
|
|
595
|
+
Span::styled(" rename ", Style::default().fg(self.theme.accent_dim)),
|
|
517
596
|
Span::styled("/", Style::default().fg(self.theme.accent)),
|
|
518
597
|
Span::styled(" search ", Style::default().fg(self.theme.accent_dim)),
|
|
519
598
|
Span::styled("x", Style::default().fg(self.theme.accent)),
|
|
@@ -570,11 +649,19 @@ impl PopupRenderer {
|
|
|
570
649
|
Style::default()
|
|
571
650
|
};
|
|
572
651
|
|
|
573
|
-
let label =
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
652
|
+
let label = if is_cursor {
|
|
653
|
+
browser
|
|
654
|
+
.rename_buffer
|
|
655
|
+
.as_ref()
|
|
656
|
+
.map(|name| format!("{name}_ "))
|
|
657
|
+
.or_else(|| run.name.as_deref().map(|n| format!("{} ", n)))
|
|
658
|
+
.unwrap_or_default()
|
|
659
|
+
} else {
|
|
660
|
+
run.name
|
|
661
|
+
.as_deref()
|
|
662
|
+
.map(|n| format!("{} ", n))
|
|
663
|
+
.unwrap_or_default()
|
|
664
|
+
};
|
|
578
665
|
let date = format_date(run);
|
|
579
666
|
let config_summary = differing_config_summary(run, &diff_keys);
|
|
580
667
|
|
|
@@ -624,14 +711,24 @@ fn compute_scroll(cursor: usize, current_offset: usize, list_height: usize) -> u
|
|
|
624
711
|
}
|
|
625
712
|
}
|
|
626
713
|
|
|
627
|
-
/// Format a run
|
|
714
|
+
/// Format a run timestamp for display in the user's local timezone.
|
|
628
715
|
fn format_date(run: &Run) -> String {
|
|
629
|
-
run.ended_at
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
716
|
+
let raw = run.ended_at.as_deref().unwrap_or(&run.started_at);
|
|
717
|
+
format_local_timestamp(raw).unwrap_or_else(|| raw.chars().take(19).collect())
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
fn format_local_timestamp(raw: &str) -> Option<String> {
|
|
721
|
+
DateTime::parse_from_rfc3339(raw)
|
|
722
|
+
.ok()
|
|
723
|
+
.map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string())
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
fn is_rename_key(key: &KeyEvent) -> bool {
|
|
727
|
+
matches!(key.code, KeyCode::Char('R')) && accepts_text_modifiers(key)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
fn accepts_text_modifiers(key: &KeyEvent) -> bool {
|
|
731
|
+
key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT
|
|
635
732
|
}
|
|
636
733
|
|
|
637
734
|
/// Build search input line with blinking cursor.
|
|
@@ -655,6 +752,17 @@ fn search_footer_spans(theme: &Theme) -> Vec<Span<'static>> {
|
|
|
655
752
|
]
|
|
656
753
|
}
|
|
657
754
|
|
|
755
|
+
fn rename_footer_spans(theme: &Theme) -> Vec<Span<'static>> {
|
|
756
|
+
vec![
|
|
757
|
+
Span::styled("Type", Style::default().fg(theme.accent)),
|
|
758
|
+
Span::styled(" name ", Style::default().fg(theme.accent_dim)),
|
|
759
|
+
Span::styled("Enter", Style::default().fg(theme.accent)),
|
|
760
|
+
Span::styled(" save ", Style::default().fg(theme.accent_dim)),
|
|
761
|
+
Span::styled("Esc", Style::default().fg(theme.accent)),
|
|
762
|
+
Span::styled(" cancel", Style::default().fg(theme.accent_dim)),
|
|
763
|
+
]
|
|
764
|
+
}
|
|
765
|
+
|
|
658
766
|
/// Compare configs across all runs and return only the keys whose values differ.
|
|
659
767
|
fn differing_config_keys(runs: &[Run]) -> Vec<String> {
|
|
660
768
|
use std::collections::HashMap;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
use chrono::{DateTime, Local};
|
|
1
2
|
use ratatui::buffer::Buffer;
|
|
2
3
|
use ratatui::layout::Rect;
|
|
3
4
|
use ratatui::style::{Color, Modifier, Style};
|
|
@@ -219,7 +220,7 @@ impl SummaryRenderer {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
let run = &data.runs[i];
|
|
222
|
-
let date =
|
|
223
|
+
let date = format_run_time(run);
|
|
223
224
|
let label = run.name.clone().unwrap_or_else(|| {
|
|
224
225
|
let id = &run.id;
|
|
225
226
|
if id.len() > 8 { id[id.len() - 8..].to_string() } else { id.clone() }
|
|
@@ -632,6 +633,13 @@ impl SummaryRenderer {
|
|
|
632
633
|
}
|
|
633
634
|
}
|
|
634
635
|
|
|
636
|
+
fn format_run_time(run: &Run) -> String {
|
|
637
|
+
DateTime::parse_from_rfc3339(&run.started_at)
|
|
638
|
+
.ok()
|
|
639
|
+
.map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string())
|
|
640
|
+
.unwrap_or_else(|| run.started_at.chars().take(19).collect())
|
|
641
|
+
}
|
|
642
|
+
|
|
635
643
|
/// Catmull-Rom spline interpolation.
|
|
636
644
|
/// Generates `num_points` evenly spaced points along the spline that passes
|
|
637
645
|
/// through all input points. Requires at least 3 input points.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/requires.txt
RENAMED
|
File without changes
|
{extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|