diff --git a/agentdescent/cli.py b/agentdescent/cli.py index abc82a31..2aa24f42 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -4770,12 +4770,12 @@ def build_parser() -> argparse.ArgumentParser: s.set_defaults(fn=cmd_status) s = sub.add_parser("watch", help="follow a run's rounds until it ends") - s.add_argument("run_id") + s.add_argument("run_id", help="a run id, or `latest` for the newest run") s.add_argument("--interval", type=float, default=5.0) s.set_defaults(fn=cmd_watch) s = sub.add_parser("show", help="the evolved artifact, and the diff against the original") - s.add_argument("run_id") + s.add_argument("run_id", help="a run id, or `latest` for the newest run") s.add_argument("--diff", action="store_true", default=True) s.add_argument("--no-diff", action="store_false", dest="diff") s.set_defaults(fn=cmd_show) @@ -5002,7 +5002,7 @@ def build_parser() -> argparse.ArgumentParser: s2.set_defaults(fn=cmd_algorithms) s = sub.add_parser("diagnose", help="why a run learned nothing (or stopped)") - s.add_argument("run_id") + s.add_argument("run_id", help="a run id, or `latest` for the newest run") s.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="machine-readable output") s.set_defaults(fn=cmd_diagnose) @@ -5424,6 +5424,33 @@ def _accept_json_after_verb(sub: "argparse._SubParsersAction") -> None: help="machine-readable output") +#: Words that stand for "the newest run in the store" wherever a run id goes (#892). +LATEST_RUN_ALIASES = ("latest", "last") + + +def _resolve_latest_run(a: argparse.Namespace) -> None: + """Swap ``latest`` / ``last`` for the newest run id on run-id commands. + + A real run directory with that name still wins, and an empty store is a + clear error rather than ``no run 'latest'``. + """ + if getattr(a, "fn", None) not in (cmd_status, cmd_watch, cmd_show, cmd_apply, + cmd_cancel, cmd_resume, cmd_diagnose): + return + rid = getattr(a, "run_id", None) + if not isinstance(rid, str) or rid.lower() not in LATEST_RUN_ALIASES: + return + store = getattr(a, "store", None) + if os.path.isdir(os.path.join(runstore.store_path(store), rid)): + return + runs = runstore.list_runs(store=store) + if not runs: + raise runstore.RunStoreError( + f"no runs under {runstore.store_path(store)} yet, so {rid!r} has nothing " + "to point at; start one with `agentdescent demo` or `agentdescent evolve`") + a.run_id = runs[0].run_id + + def main(argv: Optional[Sequence[str]] = None) -> int: args = list(sys.argv[1:] if argv is None else argv) _CliParser.json_errors = _wants_json(args) @@ -5432,6 +5459,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: finally: _CliParser.json_errors = False try: + _resolve_latest_run(a) return a.fn(a) except (SpecError, runstore.RunStoreError) as e: # `--json` callers get the same object `_spec_error` prints (#658). diff --git a/changelog.d/892-latest-run-alias.md b/changelog.d/892-latest-run-alias.md new file mode 100644 index 00000000..cf379952 --- /dev/null +++ b/changelog.d/892-latest-run-alias.md @@ -0,0 +1,5 @@ +Added +`status`, `watch`, `show`, `apply`, `cancel`, `resume` and `diagnose` accept +`latest` (or `last`) in place of a run id and use the newest run in the store, +so `agentdescent show latest` works straight after a demo; an empty store +says so instead of `no run 'latest'` (#892). diff --git a/tests/test_latest_run_alias.py b/tests/test_latest_run_alias.py new file mode 100644 index 00000000..ee1fc704 --- /dev/null +++ b/tests/test_latest_run_alias.py @@ -0,0 +1,44 @@ +"""`latest` / `last` stand for the newest run id on run-id commands (#892).""" + +import json +import os + +from agentdescent import cli, runstore + + +def _mk(store, rid): + return runstore.create({"kind": "text", "target": "x.txt"}, run_id=rid, store=store) + + +def test_latest_resolves_to_newest_run(tmp_path, capsys): + store = str(tmp_path / "runs") + _mk(store, "20260101-000000-aaaaaa") + _mk(store, "20260202-000000-bbbbbb") + for word in ("latest", "last", "LATEST"): + assert cli.main(["--store", store, "--json", "status", word]) == 0 + out = json.loads(capsys.readouterr().out) + assert out["run_id"] == "20260202-000000-bbbbbb" + + +def test_real_run_named_latest_wins(tmp_path, capsys): + store = str(tmp_path / "runs") + _mk(store, "latest") + _mk(store, "20990101-000000-cccccc") + assert cli.main(["--store", store, "--json", "status", "latest"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == "latest" + + +def test_latest_on_empty_store_is_clear(tmp_path, capsys): + store = str(tmp_path / "empty") + assert cli.main(["--store", store, "--json", "show", "latest"]) == 2 + out = json.loads(capsys.readouterr().out) + assert out["ok"] is False + assert "no runs under" in out["error"] and "agentdescent demo" in out["error"] + assert not os.path.exists(os.path.join(store, "latest")) + + +def test_other_ids_untouched(tmp_path, capsys): + store = str(tmp_path / "runs") + _mk(store, "20260101-000000-aaaaaa") + assert cli.main(["--store", store, "status", "nope"]) == 2 + assert "no run 'nope'" in capsys.readouterr().err