Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
> [docs.openadapt.ai/reference/cli](https://docs.openadapt.ai/reference/cli/).
> Run `openadapt flow <command> --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

Expand All @@ -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:
Expand Down
42 changes: 32 additions & 10 deletions openadapt/cli.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Unified CLI for OpenAdapt ecosystem.

Usage:
openadapt flow record --url <app> --out rec # record a workflow
openadapt flow compile rec --out bundle # compile it
openadapt record --url <app> --out rec # record a workflow
openadapt compile rec --out bundle # compile it
openadapt flow replay bundle --url <app> # run it, local, $0
openadapt flow lint bundle
openadapt flow certify bundle --policy clinical-write
Expand Down Expand Up @@ -39,6 +39,13 @@
from openadapt.version import __version__


class _FlowAliasCommand(click.Command):
"""Parse a root alias exactly as the existing ``flow <verb>`` 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``.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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={
Expand Down
3 changes: 2 additions & 1 deletion packages/flow/openadapt_flow/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 — "
Expand Down
100 changes: 100 additions & 0 deletions packages/flow/tests/test_cli_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 68 additions & 2 deletions tests/test_cli_dead_ends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand All @@ -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)

Expand Down Expand Up @@ -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
Loading
Loading