From fcb84776a59c86cf132fdd2b7b515c863f4d3f30 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 11:35:12 -0400 Subject: [PATCH] feat: expose authenticated capture control --- docs/packages/capture.md | 12 ++- docs/platform-compatibility-report.md | 2 +- openadapt/cli.py | 97 ++++++++++++++++-- platform-manifest.json | 4 +- pyproject.toml | 9 +- tests/test_cli_smoke.py | 139 +++++++++++++++++++++++++- 6 files changed, 240 insertions(+), 23 deletions(-) diff --git a/docs/packages/capture.md b/docs/packages/capture.md index a30a962c4..ef21ff0af 100644 --- a/docs/packages/capture.md +++ b/docs/packages/capture.md @@ -52,11 +52,17 @@ The compatibility launcher can start a raw Capture session: ```bash openadapt capture start --name my-task +openadapt capture status --session-id SESSION_ID +openadapt capture stop --session-id SESSION_ID ``` -Stop it with Ctrl-C in the same terminal. The separate `openadapt capture stop` -command does not control another process and returns non-success. Use -`openadapt flow record` when the output must compile directly. +The start command prints the exact session ID after the recorder is ready. +`status` reads the authenticated state of that process. `stop` sends an +authenticated owner-only loopback request, waits for the writers to stop, and +returns success only after Capture verifies the finalized session. You can +omit `--session-id` only when one recorder is active. Ctrl-C in the recorder +terminal remains available. Use `openadapt flow record` when the output must +compile directly. ## Storage and privacy diff --git a/docs/platform-compatibility-report.md b/docs/platform-compatibility-report.md index c0023523b..b9c436ab4 100644 --- a/docs/platform-compatibility-report.md +++ b/docs/platform-compatibility-report.md @@ -2,7 +2,7 @@ -- Generated: `2026-08-20T15:18:32+00:00` +- Generated: `2026-08-20T15:34:42+00:00` - Release channel: **beta** - Selection: `latest-published` - Compatibility: **dependency-compatible** diff --git a/openadapt/cli.py b/openadapt/cli.py index 1e5761a9c..2f28ca41f 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -8,6 +8,7 @@ openadapt flow certify bundle --policy clinical-write openadapt capture start --name my-task + openadapt capture status openadapt capture stop openadapt capture list openadapt capture view @@ -479,6 +480,7 @@ def capture(): \b Examples: openadapt capture start --name login-flow + openadapt capture status openadapt capture stop openadapt capture list openadapt capture view login-flow @@ -508,7 +510,13 @@ def capture_start(name: str, video: bool, audio: bool): raise click.ClickException( "Capture did not become ready. No successful capture was saved." ) + session_id = recorder.control_session_id click.echo("Recording...") + click.echo(f"Capture session: {session_id}") + click.echo( + "Stop from another terminal: " + f"openadapt capture stop --session-id {session_id}" + ) try: while recorder.is_recording: import time @@ -525,15 +533,88 @@ def capture_start(name: str, video: bool, audio: bool): sys.exit(1) +@capture.command("status") +@click.option( + "--session-id", + help="Exact Capture session ID. Omit only when one recorder is active.", +) +@click.option( + "--timeout", + type=click.FloatRange(min=0.0, min_open=True), + default=5.0, + show_default=True, + help="Maximum seconds to wait for the recorder.", +) +@click.option( + "--runtime-dir", + type=click.Path(path_type=Path, file_okay=False), + help="Owner-only Capture control runtime directory override.", +) +def capture_status( + session_id: Optional[str], timeout: float, runtime_dir: Optional[Path] +) -> None: + """Show authenticated status for one active Capture recorder.""" + import json + + try: + from openadapt_capture import CaptureControlError, status_recording + except ImportError as exc: + raise click.ClickException( + "openadapt-capture 1.3.0 or newer is required. Install or upgrade " + "openadapt[capture]." + ) from exc + + try: + current = status_recording( + session_id, + timeout=timeout, + runtime_dir=runtime_dir, + ) + except CaptureControlError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(json.dumps(current.__dict__, sort_keys=True)) + + @capture.command("stop") -def capture_stop(): - """Explain how to stop a capture started in another terminal.""" - raise click.ClickException( - "No separate capture-stop control channel is available. Stop the capture " - "with Ctrl+C in the recorder terminal. A separate stop command will remain " - "unavailable until Capture provides an authenticated, owner-only local " - "control channel." - ) +@click.option( + "--session-id", + help="Exact Capture session ID. Omit only when one recorder is active.", +) +@click.option( + "--timeout", + type=click.FloatRange(min=0.0, min_open=True), + default=60.0, + show_default=True, + help="Maximum seconds to wait for verified finalization.", +) +@click.option( + "--runtime-dir", + type=click.Path(path_type=Path, file_okay=False), + help="Owner-only Capture control runtime directory override.", +) +def capture_stop( + session_id: Optional[str], timeout: float, runtime_dir: Optional[Path] +) -> None: + """Stop one recorder and wait for verified Capture finalization.""" + import json + + try: + from openadapt_capture import CaptureControlError, stop_recording + except ImportError as exc: + raise click.ClickException( + "openadapt-capture 1.3.0 or newer is required. Install or upgrade " + "openadapt[capture]." + ) from exc + + try: + completed = stop_recording( + session_id, + timeout=timeout, + runtime_dir=runtime_dir, + ) + except CaptureControlError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(json.dumps(completed.__dict__, sort_keys=True)) @capture.command("list") diff --git a/platform-manifest.json b/platform-manifest.json index c5a093167..67dc0b461 100644 --- a/platform-manifest.json +++ b/platform-manifest.json @@ -1,7 +1,7 @@ { "manifest_kind": "openadapt-platform-release-manifest", "schema_version": "1.3.0", - "generated_at": "2026-08-20T15:18:32+00:00", + "generated_at": "2026-08-20T15:34:42+00:00", "generation": { "files": [ { @@ -1091,7 +1091,7 @@ "python": ">=3.10,<3.13", "launcher_requires": { "openadapt-capture": [ - ">=1.2.0,<2.0.0" + ">=1.3.0,<2.0.0" ], "openadapt-evals": [ ">=0.1.0" diff --git a/pyproject.toml b/pyproject.toml index 69743361e..2e8805064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,11 +47,12 @@ browser = [ "openadapt-flow[browser]>=1.29.0,<2.0.0", ] # Local human desktop recording. Flow owns the supported capture adapter -# contract; the direct floor prevents an already-installed pre-1.0 Capture -# from satisfying the launcher's public recording path. FFmpeg remains a -# separately provisioned executable, not a Python-package dependency. +# contract. Floor 1.3.0 supplies the authenticated recorder status/stop +# channel and the resilient native geometry contract used by this launcher. +# FFmpeg remains a separately provisioned executable, not a Python-package +# dependency. capture = [ - "openadapt-capture>=1.2.0,<2.0.0", + "openadapt-capture>=1.3.0,<2.0.0", "openadapt-flow[capture]>=1.29.0,<2.0.0", ] # Replay substrate dependencies stay separate from capture: recording observes diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index aec814efa..93be14d05 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -268,7 +268,7 @@ def test_launcher_flow_and_substrate_extras_metadata(): assert extras["browser"] == ["openadapt-flow[browser]>=1.29.0,<2.0.0"] assert extras["privacy"] == ["openadapt-flow[privacy]>=1.29.0,<2.0.0"] assert extras["capture"] == [ - "openadapt-capture>=1.2.0,<2.0.0", + "openadapt-capture>=1.3.0,<2.0.0", "openadapt-flow[capture]>=1.29.0,<2.0.0", ] assert extras["windows"] == ["openadapt-flow[windows]>=1.29.0,<2.0.0"] @@ -505,13 +505,142 @@ def wait_for_ready(self): assert "Capture saved" not in result.output -def test_capture_stop_fails_until_capture_has_a_control_channel(): +def test_capture_start_prints_exact_control_session(monkeypatch): + from types import SimpleNamespace + + class ReadyRecorder: + control_session_id = "session-123" + event_count = 7 + is_recording = False + + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def wait_for_ready(self): + return True + + monkeypatch.setitem( + sys.modules, + "openadapt_capture", + SimpleNamespace(Recorder=ReadyRecorder), + ) + + result = CliRunner().invoke( + cli_main, + ["capture", "start", "--name", "ready-capture", "--no-video"], + ) + + assert result.exit_code == 0, result.output + assert "Capture session: session-123" in result.output + assert "openadapt capture stop --session-id session-123" in result.output + assert "Capture saved: ./ready-capture/ (7 events)" in result.output + + +def _capture_control_module(monkeypatch, *, error=None): + from types import SimpleNamespace + + class CaptureControlError(RuntimeError): + pass + + calls = [] + + def status_recording(session_id, **kwargs): + calls.append(("status", session_id, kwargs)) + if error is not None: + raise CaptureControlError(error) + return SimpleNamespace( + session_id=session_id or "only-active-session", + phase="recording", + ready=True, + complete=False, + integrity_verified=False, + event_counts={"action": 7}, + ) + + def stop_recording(session_id, **kwargs): + calls.append(("stop", session_id, kwargs)) + if error is not None: + raise CaptureControlError(error) + return SimpleNamespace( + session_id=session_id or "only-active-session", + phase="complete", + ready=True, + complete=True, + integrity_verified=True, + event_counts={"action": 7}, + ) + + monkeypatch.setitem( + sys.modules, + "openadapt_capture", + SimpleNamespace( + CaptureControlError=CaptureControlError, + status_recording=status_recording, + stop_recording=stop_recording, + ), + ) + return calls + + +def test_capture_status_uses_authenticated_public_control(monkeypatch, tmp_path): + calls = _capture_control_module(monkeypatch) + + result = CliRunner().invoke( + cli_main, + [ + "capture", + "status", + "--session-id", + "session-123", + "--timeout", + "2.5", + "--runtime-dir", + str(tmp_path), + ], + ) + + assert result.exit_code == 0, result.output + assert calls == [ + ( + "status", + "session-123", + {"timeout": 2.5, "runtime_dir": tmp_path}, + ) + ] + assert '"phase": "recording"' in result.output + assert '"integrity_verified": false' in result.output + + +def test_capture_stop_waits_for_verified_finalization(monkeypatch): + calls = _capture_control_module(monkeypatch) + + result = CliRunner().invoke( + cli_main, + ["capture", "stop", "--session-id", "session-123", "--timeout", "45"], + ) + + assert result.exit_code == 0, result.output + assert calls == [("stop", "session-123", {"timeout": 45.0, "runtime_dir": None})] + assert '"phase": "complete"' in result.output + assert '"integrity_verified": true' in result.output + + +def test_capture_control_failure_returns_non_success(monkeypatch): + calls = _capture_control_module( + monkeypatch, error="No unambiguous live Capture recorder was found." + ) + result = CliRunner().invoke(cli_main, ["capture", "stop"]) assert result.exit_code != 0 - assert "Ctrl+C in the recorder terminal" in result.output - assert "authenticated, owner-only local control channel" in result.output - assert "Stopping active capture session" not in result.output + assert calls == [("stop", None, {"timeout": 60.0, "runtime_dir": None})] + assert "No unambiguous live Capture recorder was found" in result.output def test_flow_help_is_current_engine_help():