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
12 changes: 9 additions & 3 deletions docs/packages/capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/platform-compatibility-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- Generated by scripts/render_platform_versions.py. Do not edit. -->

- 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**
Expand Down
97 changes: 89 additions & 8 deletions openadapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions platform-manifest.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down Expand Up @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 134 additions & 5 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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():
Expand Down
Loading