From 322af85e186e675a00056e07b5fe18226099baf4 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 12:44:37 -0500 Subject: [PATCH] cli: add root workflow aliases and plain result guidance Signed-off-by: abrichr --- README.md | 6 +- docs/cli.md | 10 +-- openadapt/cli.py | 42 ++++++--- packages/flow/openadapt_flow/__main__.py | 3 +- packages/flow/tests/test_cli_deployment.py | 100 +++++++++++++++++++++ tests/test_cli_dead_ends.py | 70 ++++++++++++++- tests/test_cli_smoke.py | 33 ++++--- 7 files changed, 225 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d4e2abe323..d34ae51228 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,8 @@ covers other clients. ```bash python -m pip install --upgrade openadapt -openadapt flow record --url https://staging.your-app.example --out rec -openadapt flow compile rec --out bundle --name my-first-workflow +openadapt record --url https://staging.your-app.example --out rec +openadapt compile rec --out bundle --name my-first-workflow openadapt flow replay bundle --url https://staging.your-app.example --run-dir run ``` @@ -185,7 +185,7 @@ including saves you can't undo. It doesn't ask first and doesn't check the saved record. Point it at a test or staging copy, never at production data. A program that writes to a real system also needs record checks, a deployment -file, certification, qualification, and an admission; then `openadapt flow run` +file, certification, qualification, and an admission; then `openadapt run` runs it. By default each save waits for a person to approve it in OpenAdapt Desktop or on a paired phone. [Your first workflow](https://docs.openadapt.ai/tutorials/first-workflow/) walks through it. Several recordings of the same task can become one program diff --git a/docs/cli.md b/docs/cli.md index 7505a57479..467e6e1c6c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,8 +4,9 @@ > [docs.openadapt.ai/reference/cli](https://docs.openadapt.ai/reference/cli/). > Run `openadapt flow --help` for the exact installed engine version. -The launcher command is `openadapt`. It delegates the product lifecycle to the -installed `openadapt-flow` engine. +Install with `pip install openadapt`. Use `openadapt record`, +`openadapt compile` and `openadapt run` for the workflow lifecycle. +Their `openadapt flow` forms remain available with identical options and checks. ## First run @@ -21,9 +22,8 @@ runs the bundled synthetic MockMed workflow from recording through an independently verified Standard-profile result. `openadapt quickstart --break-it` is the halt demo. It reruns the same certified bundle against a backend that rejects the write after the app shows success. The independent read finds no -saved record, and the run halts with transaction outcome -`RECONCILIATION_REQUIRED`, so nothing is retried until a person checks the -record. +saved record, and the result says “Check the record.” Nothing is retried +until a person checks the record. Exact outcomes remain in the run report. The engine runs on Python 3.10 through 3.12. On Python 3.13 or newer, pip installs only the launcher and every engine command prints the fix: diff --git a/openadapt/cli.py b/openadapt/cli.py index d0a9b0b32e..2deb5650dd 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -1,8 +1,8 @@ """Unified CLI for OpenAdapt ecosystem. Usage: - openadapt flow record --url --out rec # record a workflow - openadapt flow compile rec --out bundle # compile it + openadapt record --url --out rec # record a workflow + openadapt compile rec --out bundle # compile it openadapt flow replay bundle --url # run it, local, $0 openadapt flow lint bundle openadapt flow certify bundle --policy clinical-write @@ -39,6 +39,13 @@ from openadapt.version import __version__ +class _FlowAliasCommand(click.Command): + """Parse a root alias exactly as the existing ``flow `` form.""" + + def parse_args(self, ctx, args): + return super().parse_args(ctx, [self.name, *args]) + + class _FlowFirstGroup(click.Group): """Command group that lists ``flow`` first in ``--help``. @@ -99,7 +106,7 @@ def _print_version(ctx: click.Context, _param, value: bool) -> None: help="Show the version and exit.", ) def main(): - """OpenAdapt launcher for the openadapt-flow compiler. + """Record, compile and run workflows with OpenAdapt. Compile a demonstrated workflow into deterministic local replay. Healthy runs make no model calls; configured checks can halt on ambiguity. Native @@ -114,7 +121,7 @@ def main(): \b Manual Demo Lifecycle: openadapt flow demo-record --out rec - openadapt flow compile rec --out bundle --name demo + openadapt compile rec --out bundle --name demo openadapt flow lint bundle openadapt flow certify bundle --policy permissive openadapt flow replay bundle --demo-app @@ -257,7 +264,6 @@ def _invoke_flow(argv: list[str]) -> int: except ImportError: click.echo("Error: openadapt-flow not installed.", err=True) click.echo("Reinstall with: pip install --upgrade openadapt", err=True) - click.echo("Engine only: pip install openadapt-flow", err=True) return 1 _correct_released_flow_x11_sonames() @@ -440,12 +446,9 @@ def _halted_rerun_result(run_dir: Path) -> str: """ outcome = _transaction_outcome(run_dir) if outcome == "HALTED_BEFORE_EFFECT": - return ( - "Result: stopped before saving (transaction outcome " - "HALTED_BEFORE_EFFECT). The engine proved nothing was written." - ) + return "Result: Stopped before saving. The engine proved nothing was written." return ( - f"Result: check the record (transaction outcome {outcome or 'unknown'}). " + "Result: Check the record. " "A save may have gone through, so nothing is retried until a person " "checks the record." ) @@ -1139,6 +1142,25 @@ def flow(command_ctx: click.Context) -> None: _run_flow(list(command_ctx.args)) +for _verb, _help in ( + ("record", "Record a workflow. Uses the same options as flow record."), + ("compile", "Compile a recording. Uses the same options as flow compile."), + ("run", "Run a bundle with its checks. Uses the same options as flow run."), +): + main.add_command( + _FlowAliasCommand( + _verb, + callback=flow.callback, + help=_help, + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + "help_option_names": [], + }, + ) + ) + + @main.command( "pull", context_settings={ diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index bd65924ff0..3d3d5cd554 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -1305,7 +1305,8 @@ def _finish_replay( ) plain = plain_result(getattr(report, "transaction_outcome", None), outcome) print(f"Result: {plain.label}. {plain.meaning}") - print(f"Replay {outcome}: {report_md}") + print(f"Next: {plain.next_step.replace('openadapt-flow ', 'openadapt flow ')}") + print(f"Report: {report_md}") if report.screenshots_may_leave_box: print( "NOTE: a model-grounding component was wired for this run — " diff --git a/packages/flow/tests/test_cli_deployment.py b/packages/flow/tests/test_cli_deployment.py index d2b1932300..3b99f282da 100644 --- a/packages/flow/tests/test_cli_deployment.py +++ b/packages/flow/tests/test_cli_deployment.py @@ -139,3 +139,103 @@ def test_api_actuator_off_by_default_on_when_enabled() -> None: act = build_api_actuator(ActuationConfig(api=True, base_url="http://api")) assert type(act).__name__ == "ApiActuator" assert act.base_url == "http://api" + + +@pytest.mark.parametrize( + ("transaction", "execution", "success", "profile", "label", "exit_code"), + [ + ("VERIFIED", "VERIFIED", True, "standard", "Done and checked", 0), + ( + "COMPLETED_UNVERIFIED", + "COMPLETED_UNVERIFIED", + True, + "demo", + "Finished, not checked", + 0, + ), + ( + "COMPLETED_UNVERIFIED", + "COMPLETED_UNVERIFIED", + True, + "standard", + "Finished, not checked", + 1, + ), + ( + "HALTED_BEFORE_EFFECT", + "HALTED", + False, + "standard", + "Stopped before saving", + 1, + ), + ("RECONCILIATION_REQUIRED", "HALTED", False, "standard", "Check the record", 1), + ("ROLLED_BACK", "HALTED", False, "standard", "Check the record", 1), + ( + "SAVED_AWAITING_CHECK", + "SAVED_AWAITING_CHECK", + False, + "standard", + "Saved, waiting for its check", + 1, + ), + (None, "HALTED", False, "standard", "Check the record", 1), + ("UNKNOWN_NEW_STATE", "HALTED", False, "standard", "Check the record", 1), + ], +) +def test_run_summary_keeps_result_safety_and_exit_policy( + tmp_path, + monkeypatch, + capsys, + transaction, + execution, + success, + profile, + label, + exit_code, +): + from types import SimpleNamespace + + from openadapt_flow import __main__ as cli + from openadapt_flow import report as report_module + from openadapt_flow import rollback + + retained = tmp_path / "report.json" + original = b'{"transaction_outcome":"RECONCILIATION_REQUIRED","unchanged":true}' + retained.write_bytes(original) + monkeypatch.setattr( + report_module, "render_run_report", lambda folder: folder / "REPORT.md" + ) + for helper in ( + "_maybe_report_break", + "_maybe_report_run", + "_maybe_attest_run", + ): + monkeypatch.setattr(cli, helper, lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "_replay_outcome_epilogue", lambda *args: "") + monkeypatch.setattr(cli, "_change_ledger_epilogue", lambda *args: "") + monkeypatch.setattr(rollback, "rollback_lines", lambda report: []) + report = SimpleNamespace( + transaction_outcome=transaction, + execution_outcome=execution, + success=success, + execution_profile=profile, + screenshots_may_leave_box=False, + ) + + assert cli._finish_replay(tmp_path, report) == exit_code + output = capsys.readouterr().out + assert output.startswith(f"Result: {label}.") + assert "Next: " in output + assert f"Report: {tmp_path / 'REPORT.md'}" in output + assert retained.read_bytes() == original + if label == "Check the record": + assert "Check the record before anyone runs this again." in output + assert "Nothing was written" not in output + assert "run it again" not in output + if transaction == "SAVED_AWAITING_CHECK": + assert "Don't run it again." in output + assert "openadapt flow deferred settle" in output + assert "openadapt-flow" not in output + if transaction: + assert transaction not in output diff --git a/tests/test_cli_dead_ends.py b/tests/test_cli_dead_ends.py index 06ce8376b3..2aaf5030c5 100644 --- a/tests/test_cli_dead_ends.py +++ b/tests/test_cli_dead_ends.py @@ -56,7 +56,7 @@ def record(argv): return calls -@pytest.mark.parametrize("verb", ["run", "record", "replay", "compile", "certify"]) +@pytest.mark.parametrize("verb", ["replay", "certify"]) def test_flow_verb_at_top_level_points_at_openadapt_flow(verb): result = CliRunner().invoke(cli_main, [verb, "bundle"]) @@ -65,7 +65,7 @@ def test_flow_verb_at_top_level_points_at_openadapt_flow(verb): assert "deploy" not in result.output -@pytest.mark.parametrize("verb", ["run", "record", "replay"]) +@pytest.mark.parametrize("verb", ["replay", "certify"]) def test_flow_verb_hint_works_without_a_readable_flow_parser(monkeypatch, verb): monkeypatch.setattr("openadapt.cli._flow_subparsers", lambda: None, raising=False) @@ -140,3 +140,69 @@ def test_installed_flow_tutorial_exposes_the_documented_flags(): assert options is not None assert {"--break-it", "--guided", "--interactive-record"} <= options + + +@pytest.mark.parametrize("verb", ["record", "compile", "run"]) +@pytest.mark.parametrize("exit_code", [0, 2, 7]) +@pytest.mark.parametrize( + "arguments", + [ + [], + ["--help"], + ["--future-option=value", "a recording", "--param", "x=1", "--param", "x=2"], + ["--", "--literal-path", "value with spaces"], + ], +) +def test_root_alias_preserves_existing_flow_arguments_output_and_exit( + monkeypatch, supported_python, verb, exit_code, arguments +): + calls = [] + + def engine(argv): + calls.append(list(argv)) + cli.click.echo('{"outcome":"UNCERTAIN","technical":"RECONCILIATION_REQUIRED"}') + cli.click.echo("engine diagnostic", err=True) + return exit_code + + monkeypatch.setattr(cli, "_invoke_flow", engine) + runner = CliRunner() + old = runner.invoke(cli_main, ["flow", verb, *arguments]) + alias = runner.invoke(cli_main, [verb, *arguments]) + + assert len(calls) == 2 + assert calls[0] == calls[1] + assert calls[0][0] == verb + assert old.exit_code == alias.exit_code == exit_code + assert ( + old.stdout + == alias.stdout + == ('{"outcome":"UNCERTAIN","technical":"RECONCILIATION_REQUIRED"}\n') + ) + assert old.stderr == alias.stderr == "engine diagnostic\n" + + +def test_root_aliases_and_help_do_not_need_the_engine_parser( + monkeypatch, supported_python +): + def unavailable(): + raise AssertionError("root aliases must not load the engine parser") + + calls = [] + monkeypatch.setattr(cli, "_flow_subparsers", unavailable) + monkeypatch.setattr(cli, "_invoke_flow", lambda argv: calls.append(argv) or 0) + runner = CliRunner() + help_result = runner.invoke(cli_main, ["--help"]) + assert help_result.exit_code == 0, help_result.output + for verb in ("record", "compile", "run"): + assert verb in help_result.output + result = runner.invoke(cli_main, [verb, "--future-option"]) + assert result.exit_code == 0, result.output + assert calls == [[verb, "--future-option"] for verb in ("record", "compile", "run")] + + +@pytest.mark.parametrize("verb", ["record", "compile", "run"]) +def test_root_alias_keeps_the_supported_python_guard(monkeypatch, verb): + monkeypatch.setattr(cli, "_python_minor", lambda: (3, 13)) + result = CliRunner().invoke(cli_main, [verb, "--help"]) + assert result.exit_code == 1 + assert "uv tool install --python 3.12 openadapt" in result.output diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 0b08f91c71..ad8eafdba3 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -169,10 +169,7 @@ def test_quickstart_forwards_the_break_it_option_and_names_the_evidence( assert "unchanged" not in result.output # vocab-allow: no-change-claim -- asserts that the claim is absent assert "nothing was written" not in result.output - assert ( - "Result: check the record (transaction outcome RECONCILIATION_REQUIRED)" - in result.output - ) + assert "Result: Check the record." in result.output assert "nothing is retried until a person checks the record" in result.output @@ -181,27 +178,26 @@ def test_quickstart_forwards_the_break_it_option_and_names_the_evidence( [ ( '{"transaction_outcome": "HALTED_BEFORE_EFFECT"}', - "Result: stopped before saving (transaction outcome " - "HALTED_BEFORE_EFFECT). The engine proved nothing was written.", + "Result: Stopped before saving. The engine proved nothing was written.", ), ( '{"transaction_outcome": "RECONCILIATION_REQUIRED"}', - "Result: check the record (transaction outcome RECONCILIATION_REQUIRED)", + "Result: Check the record.", ), # A value this launcher doesn't know, a coarse HALTED with no # transaction outcome, an unreadable report, or no report at all # can't prove that nothing was written. ( '{"transaction_outcome": "SOMETHING_NEW"}', - "Result: check the record (transaction outcome SOMETHING_NEW)", + "Result: Check the record.", ), ( '{"execution_outcome": "HALTED"}', - "Result: check the record (transaction outcome unknown)", + "Result: Check the record.", ), - ("not json", "Result: check the record (transaction outcome unknown)"), - ("[]", "Result: check the record (transaction outcome unknown)"), - (None, "Result: check the record (transaction outcome unknown)"), + ("not json", "Result: Check the record."), + ("[]", "Result: Check the record."), + (None, "Result: Check the record."), ], ) def test_quickstart_derives_the_halted_rerun_result_from_its_report( @@ -221,7 +217,7 @@ def test_quickstart_derives_the_halted_rerun_result_from_its_report( assert result.exit_code == 0, result.output assert expected in result.output - if "HALTED_BEFORE_EFFECT" not in expected: + if expected.startswith("Result: Check the record"): assert "nothing was written" not in result.output assert "A save may have gone through" in result.output @@ -1018,7 +1014,10 @@ def test_flow_record_web_invocation_forwards_verbatim(monkeypatch): ] -def test_flow_missing_shows_install_hint(monkeypatch): +@pytest.mark.parametrize( + "command", [["flow", "compile"], ["compile"], ["record"], ["run"]] +) +def test_flow_missing_shows_install_hint(monkeypatch, command): """When openadapt-flow isn't installed, `openadapt flow ` exits nonzero with a pip install hint instead of a traceback.""" import builtins @@ -1035,12 +1034,10 @@ def broken_import(name, *args, **kwargs): monkeypatch.delitem(sys.modules, "openadapt_flow.__main__", raising=False) runner = CliRunner() - result = runner.invoke( - cli_main, ["flow", "compile", "rec", "--out", "b", "--name", "x"] - ) + result = runner.invoke(cli_main, [*command, "rec", "--out", "b", "--name", "x"]) assert result.exit_code != 0 assert "pip install --upgrade openadapt" in result.output - assert "pip install openadapt-flow" in result.output + assert "pip install openadapt-flow" not in result.output def _require_openadapt_flow():