From 9520597af49fdb33f27c3dfaaf6f0fc17fcc7ef2 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 18 Aug 2026 12:09:51 -0400 Subject: [PATCH 1/4] fix: require Flow production release evidence --- .github/workflows/quickstart-lifecycle.yml | 2 +- pyproject.toml | 8 +- scripts/check_release_ci.py | 119 ++++++++++++++++++++- scripts/quickstart_lifecycle.py | 62 ++++++++--- tests/test_release_ci_gate.py | 55 +++++++++- tests/test_release_contract.py | 20 +++- 6 files changed, 247 insertions(+), 19 deletions(-) diff --git a/.github/workflows/quickstart-lifecycle.yml b/.github/workflows/quickstart-lifecycle.yml index b57b3758..7d2f77f0 100644 --- a/.github/workflows/quickstart-lifecycle.yml +++ b/.github/workflows/quickstart-lifecycle.yml @@ -104,4 +104,4 @@ jobs: runs/lifecycle/artifacts/**/REPORT.md runs/lifecycle/artifacts/**/report.json runs/lifecycle/artifacts/**/patch.json - if-no-files-found: warn + if-no-files-found: error diff --git a/pyproject.toml b/pyproject.toml index c4a8cfcc..7ebdec40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,13 @@ authors = [{ name = "OpenAdapt.AI" }] dependencies = [ "pydantic>=2.5", "numpy>=1.26", - "opencv-python-headless>=4.9", + # rapidocr-onnxruntime requires opencv-python by distribution name. Python + # packaging has no provider/alias mechanism through which the headless + # distribution can satisfy that requirement. Declaring both installs two + # distributions that own the same cv2 package. Use the one provider that + # RapidOCR's published metadata requires, and enforce this in the clean + # wheel lifecycle on every supported OS. + "opencv-python>=4.9", "pillow>=10.0", "rapidocr-onnxruntime>=1.3", # GPU-less runners call the on-prem VLM appliance over HTTP diff --git a/scripts/check_release_ci.py b/scripts/check_release_ci.py index 9de3d1df..28899f29 100644 --- a/scripts/check_release_ci.py +++ b/scripts/check_release_ci.py @@ -24,6 +24,13 @@ "test-matrix (macos-latest, 3.12)", } ) +EXPECTED_CLEAN_MACHINE_JOBS = frozenset( + { + "lifecycle (ubuntu-latest)", + "lifecycle (macos-latest)", + "lifecycle (windows-latest)", + } +) PER_PAGE = 100 MAX_PAGES = 100 GITHUB_API_VERSION = "2022-11-28" @@ -48,6 +55,12 @@ class Qualification: job_names: frozenset[str] +@dataclass(frozen=True) +class ProductionQualification: + full_matrix: Qualification + clean_machine: Qualification + + class GitHubJSONFetcher: """Small authenticated GitHub REST reader with fail-closed decoding.""" @@ -207,6 +220,103 @@ def require_exact_full_matrix( ) +def require_exact_clean_machine( + fetch_json: JSONFetcher, + *, + repository: str, + sha: str, +) -> Qualification: + """Require the three-OS clean-wheel Browser lifecycle on the exact SHA.""" + + if not _REPOSITORY_RE.fullmatch(repository): + raise QualificationError(f"invalid GitHub repository: {repository!r}") + if not _SHA_RE.fullmatch(sha): + raise QualificationError(f"invalid Git commit SHA: {sha!r}") + + runs = _paginate( + fetch_json, + f"/repos/{repository}/actions/workflows/quickstart-lifecycle.yml/runs", + "workflow_runs", + {"head_sha": sha, "event": "workflow_dispatch"}, + ) + exact_runs = [ + run + for run in runs + if run.get("head_sha") == sha and run.get("event") == "workflow_dispatch" + ] + if not exact_runs: + raise QualificationPending( + f"no exact-SHA workflow_dispatch clean-machine run exists for {sha}" + ) + latest = max( + exact_runs, + key=lambda run: (str(run.get("created_at", "")), int(run.get("id", 0))), + ) + run_id = latest.get("id") + if not isinstance(run_id, int) or run_id <= 0: + raise QualificationError("clean-machine qualification run has an invalid id") + status = latest.get("status") + conclusion = latest.get("conclusion") + if status != "completed": + raise QualificationPending( + f"exact-SHA clean-machine run {run_id} is {status!r}" + ) + if conclusion != "success": + raise QualificationError( + f"exact-SHA clean-machine run {run_id} concluded {conclusion!r}" + ) + + jobs = _paginate( + fetch_json, + f"/repos/{repository}/actions/runs/{run_id}/jobs", + "jobs", + {"filter": "latest"}, + ) + lifecycle_jobs = [ + job + for job in jobs + if isinstance(job.get("name"), str) + and str(job["name"]).startswith("lifecycle") + ] + counts = Counter(str(job.get("name")) for job in lifecycle_jobs) + expected_counts = Counter({name: 1 for name in EXPECTED_CLEAN_MACHINE_JOBS}) + if counts != expected_counts: + raise QualificationError( + "exact-SHA clean-machine job set/count mismatch: " + f"expected={dict(sorted(expected_counts.items()))}, " + f"observed={dict(sorted(counts.items()))}" + ) + non_success = { + str(job["name"]): job.get("conclusion") + for job in lifecycle_jobs + if job.get("conclusion") != "success" + } + if non_success: + raise QualificationError( + "exact-SHA clean-machine run has non-success jobs: " + f"{dict(sorted(non_success.items()))}" + ) + return Qualification(run_id=run_id, sha=sha, job_names=frozenset(counts)) + + +def require_production_qualification( + fetch_json: JSONFetcher, + *, + repository: str, + sha: str, +) -> ProductionQualification: + """Require both code-level and clean-wheel product qualification.""" + + return ProductionQualification( + full_matrix=require_exact_full_matrix( + fetch_json, repository=repository, sha=sha + ), + clean_machine=require_exact_clean_machine( + fetch_json, repository=repository, sha=sha + ), + ) + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Require an exact-SHA dispatched CI full-matrix qualification." @@ -232,7 +342,7 @@ def main(argv: list[str] | None = None) -> int: deadline = time.monotonic() + args.wait_seconds while True: try: - qualification = require_exact_full_matrix( + qualification = require_production_qualification( fetch_json, repository=args.repository, sha=args.sha, @@ -249,8 +359,11 @@ def main(argv: list[str] | None = None) -> int: return 1 print( "Release qualification passed: " - f"sha={qualification.sha} run_id={qualification.run_id} " - f"matrix_jobs={len(qualification.job_names)}" + f"sha={qualification.full_matrix.sha} " + f"matrix_run_id={qualification.full_matrix.run_id} " + f"clean_machine_run_id={qualification.clean_machine.run_id} " + f"matrix_jobs={len(qualification.full_matrix.job_names)} " + f"clean_machine_jobs={len(qualification.clean_machine.job_names)}" ) return 0 diff --git a/scripts/quickstart_lifecycle.py b/scripts/quickstart_lifecycle.py index 88adcf25..7ef099ac 100644 --- a/scripts/quickstart_lifecycle.py +++ b/scripts/quickstart_lifecycle.py @@ -100,6 +100,38 @@ def _console_script(root: Path) -> Path: ) +def _inspect_opencv_provider( + python: Path, + *, + cwd: Path, + env: dict[str, str], + log: Path, +) -> str: + """Require one reviewed distribution to own the installed ``cv2`` package.""" + + probe = _run( + [ + str(python), + "-c", + ( + "import importlib.metadata as m, json; " + "names={d.metadata['Name'].lower() for d in m.distributions() " + "if d.metadata.get('Name')}; " + "providers=sorted(names & {'opencv-python','opencv-python-headless'," + "'opencv-contrib-python','opencv-contrib-python-headless'}); " + "import cv2; " + "print(json.dumps({'providers':providers,'cv2_version':cv2.__version__})); " + "assert providers == ['opencv-python'], providers" + ), + ], + cwd=cwd, + env=env, + log=log, + ) + payload = json.loads(probe.stdout.splitlines()[-1]) + return str(payload["providers"][0]) + + def _load_report(path: Path) -> dict: if not path.is_file(): raise AssertionError(f"missing machine-readable run report: {path}") @@ -290,6 +322,12 @@ def run_lifecycle( log=logs / "01-install.log", ) installed = True + summary["opencv_provider"] = _inspect_opencv_provider( + python, + cwd=artifacts, + env=env, + log=logs / "02-opencv-provider.log", + ) console = _console_script(venv_dir) if not console.is_file(): raise AssertionError(f"console entry point was not installed: {console}") @@ -297,7 +335,7 @@ def run_lifecycle( [str(console), "--help"], cwd=artifacts, env=env, - log=logs / "02-cli-help.log", + log=logs / "03-cli-help.log", ) # Linux needs host libraries that the ordinary unprivileged first-run @@ -312,7 +350,7 @@ def run_lifecycle( browser_command, cwd=artifacts, env=env, - log=logs / "03-browser-install.log", + log=logs / "04-browser-install.log", ) cli = [str(python), "-m", "openadapt_flow"] @@ -322,7 +360,7 @@ def run_lifecycle( [*cli, "demo-record", "--out", str(recording)], cwd=artifacts, env=env, - log=logs / "04-record.log", + log=logs / "05-record.log", ) _run( [ @@ -336,7 +374,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "05-compile.log", + log=logs / "06-compile.log", ) # The bundled tutorial is deliberately not production-certified. The @@ -349,20 +387,20 @@ def run_lifecycle( [*cli, "lint", str(bundle), "--strict"], cwd=artifacts, env=env, - log=logs / "06-strict-lint-expected-refusal.log", + log=logs / "07-strict-lint-expected-refusal.log", expected=1, ) _run( [*cli, "certify", str(bundle), "--policy", "permissive"], cwd=artifacts, env=env, - log=logs / "07-certify-permissive.log", + log=logs / "08-certify-permissive.log", ) _run( [*cli, "certify", str(bundle), "--policy", "clinical-write"], cwd=artifacts, env=env, - log=logs / "08-certify-clinical-expected-refusal.log", + log=logs / "09-certify-clinical-expected-refusal.log", expected=2, ) _run( @@ -375,7 +413,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "09-replay-baseline.log", + log=logs / "10-replay-baseline.log", ) _run( [ @@ -391,7 +429,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "10-replay-drift.log", + log=logs / "11-replay-drift.log", ) # The COMPOSED free path. Every command above passed while this loop # was broken; only running it end to end catches that. @@ -399,7 +437,7 @@ def run_lifecycle( [*cli, "tutorial", "--out", str(artifacts / "tutorial")], cwd=artifacts, env=env, - log=logs / "11-tutorial-verified.log", + log=logs / "12-tutorial-verified.log", ) summary.update(_inspect_artifacts(artifacts)) finally: @@ -408,7 +446,7 @@ def run_lifecycle( [str(python), "-m", "pip", "uninstall", "-y", "openadapt-flow"], cwd=artifacts, env=env, - log=logs / "12-uninstall.log", + log=logs / "13-uninstall.log", ) probe = _run( [ @@ -421,7 +459,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "13-uninstall-probe.log", + log=logs / "14-uninstall-probe.log", ) summary["uninstall_verified"] = probe.returncode == 0 (work_dir / "summary.json").write_text( diff --git a/tests/test_release_ci_gate.py b/tests/test_release_ci_gate.py index 7ecae0f6..0dc36e26 100644 --- a/tests/test_release_ci_gate.py +++ b/tests/test_release_ci_gate.py @@ -8,15 +8,18 @@ import pytest from scripts.check_release_ci import ( + EXPECTED_CLEAN_MACHINE_JOBS, EXPECTED_MATRIX_JOBS, QualificationError, QualificationPending, require_exact_full_matrix, + require_production_qualification, ) REPOSITORY = "OpenAdaptAI/openadapt-flow" SHA = "a" * 40 RUN_ID = 12345 +CLEAN_RUN_ID = 67890 def _run( @@ -43,17 +46,32 @@ def _matrix_jobs(*, conclusion: str = "success") -> list[dict[str, Any]]: ] +def _clean_run(**kwargs: Any) -> dict[str, Any]: + return {**_run(**kwargs), "id": CLEAN_RUN_ID} + + +def _clean_jobs(*, conclusion: str = "success") -> list[dict[str, Any]]: + return [ + {"name": name, "conclusion": conclusion} + for name in sorted(EXPECTED_CLEAN_MACHINE_JOBS) + ] + + class FakeGitHub: def __init__( self, *, runs: list[dict[str, Any]] | None = None, jobs: list[dict[str, Any]] | None = None, + clean_runs: list[dict[str, Any]] | None = None, + clean_jobs: list[dict[str, Any]] | None = None, page_size: int = 100, error_endpoint: str | None = None, ) -> None: self.runs = runs if runs is not None else [_run()] self.jobs = jobs if jobs is not None else _matrix_jobs() + self.clean_runs = clean_runs if clean_runs is not None else [_clean_run()] + self.clean_jobs = clean_jobs if clean_jobs is not None else _clean_jobs() self.page_size = page_size self.error_endpoint = error_endpoint self.calls: list[tuple[str, dict[str, str]]] = [] @@ -63,7 +81,12 @@ def __call__(self, endpoint: str, params: Mapping[str, str]) -> Mapping[str, Any self.calls.append((endpoint, params_copy)) if self.error_endpoint and self.error_endpoint in endpoint: raise QualificationError("simulated API error") - source = self.runs if endpoint.endswith("/runs") else self.jobs + if "quickstart-lifecycle.yml" in endpoint: + source = self.clean_runs + elif f"/runs/{CLEAN_RUN_ID}/jobs" in endpoint: + source = self.clean_jobs + else: + source = self.runs if endpoint.endswith("/runs") else self.jobs key = "workflow_runs" if endpoint.endswith("/runs") else "jobs" page = int(params_copy["page"]) start = (page - 1) * self.page_size @@ -75,6 +98,10 @@ def _require(fake: FakeGitHub): return require_exact_full_matrix(fake, repository=REPOSITORY, sha=SHA) +def _require_production(fake: FakeGitHub): + return require_production_qualification(fake, repository=REPOSITORY, sha=SHA) + + def test_accepts_exact_dispatched_run_with_exact_successful_matrix() -> None: result = _require(FakeGitHub()) @@ -83,6 +110,32 @@ def test_accepts_exact_dispatched_run_with_exact_successful_matrix() -> None: assert result.job_names == EXPECTED_MATRIX_JOBS +def test_production_gate_requires_exact_three_os_clean_machine_lifecycle() -> None: + result = _require_production(FakeGitHub()) + + assert result.full_matrix.run_id == RUN_ID + assert result.clean_machine.run_id == CLEAN_RUN_ID + assert result.clean_machine.job_names == EXPECTED_CLEAN_MACHINE_JOBS + + +def test_production_gate_rejects_missing_clean_machine_run() -> None: + with pytest.raises(QualificationPending, match="clean-machine run"): + _require_production(FakeGitHub(clean_runs=[])) + + +def test_production_gate_rejects_partial_clean_machine_matrix() -> None: + with pytest.raises(QualificationError, match="clean-machine job set/count mismatch"): + _require_production(FakeGitHub(clean_jobs=_clean_jobs()[:-1])) + + +def test_production_gate_rejects_skipped_clean_machine_job() -> None: + jobs = _clean_jobs() + jobs[0]["conclusion"] = "skipped" + + with pytest.raises(QualificationError, match="non-success jobs"): + _require_production(FakeGitHub(clean_jobs=jobs)) + + def test_rejects_skipped_matrix_job() -> None: jobs = _matrix_jobs() jobs[0]["conclusion"] = "skipped" diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index bc1d855c..5255e460 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -91,6 +91,18 @@ def test_release_versions_are_synchronized() -> None: assert len(set(versions.values())) == 1, versions +def test_runtime_declares_one_opencv_distribution_provider() -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + opencv = [ + requirement + for requirement in project["dependencies"] + if requirement.lower().startswith("opencv-") + ] + + assert opencv == ["opencv-python>=4.9"] + assert all("headless" not in requirement for requirement in opencv) + + def test_public_source_refuses_paid_agent_raw_evidence_and_recipes( tmp_path: Path, ) -> None: @@ -351,7 +363,7 @@ def test_release_workflow_uses_pinned_actions() -> None: assert "# v10.6.1" in workflow -def test_semantic_release_requires_dispatched_exact_head_full_matrix() -> None: +def test_semantic_release_requires_dispatched_exact_head_production_evidence() -> None: workflow = (ROOT / ".github/workflows/release.yml").read_text() triggers = workflow[workflow.index("\non:\n") : workflow.index("\njobs:\n")] @@ -374,6 +386,9 @@ def test_semantic_release_requires_dispatched_exact_head_full_matrix() -> None: assert "--wait-seconds 2700" in auto assert "--poll-seconds 10" in auto assert "gh api" not in auto + gate = (ROOT / "scripts/check_release_ci.py").read_text() + assert "require_production_qualification" in gate + assert "quickstart-lifecycle.yml" in gate def test_manual_publish_requires_dispatched_exact_target_full_matrix() -> None: @@ -391,6 +406,9 @@ def test_manual_publish_requires_dispatched_exact_target_full_matrix() -> None: assert '--sha "$TARGET_SHA"' in manual assert "--wait-seconds" not in manual assert "gh api" not in manual + gate = (ROOT / "scripts/check_release_ci.py").read_text() + assert "require_production_qualification" in gate + assert "quickstart-lifecycle.yml" in gate assert "--validate-dist-dir target/dist" in manual assert "--license-file target/LICENSE" not in manual assert "packages-dir: target/dist/" in manual From e4ef6f56af2f7f52ae9ee33840d0e351e7eec9c0 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 18 Aug 2026 12:39:15 -0400 Subject: [PATCH 2/4] fix: preserve resized capture coordinate spaces --- openadapt_flow/adapters/capture.py | 391 ++++++++++++++++++++++++----- tests/test_capture_adapter.py | 215 +++++++++++++++- tests/test_release_ci_gate.py | 4 +- 3 files changed, 540 insertions(+), 70 deletions(-) diff --git a/openadapt_flow/adapters/capture.py b/openadapt_flow/adapters/capture.py index 3586839a..bf41fedd 100644 --- a/openadapt_flow/adapters/capture.py +++ b/openadapt_flow/adapters/capture.py @@ -56,16 +56,25 @@ middle clicks, right-button double-clicks/drags, malformed shortcuts, unmapped named keys, and any unknown input action type all raise instead of being ignored. -Coordinate spaces: capture mouse coordinates are in *logical points*; -captured frames are *physical pixels*. openadapt-flow requires event coordinates in -the same pixel space as the frames, so points are scaled by -``CaptureSession.pixel_ratio`` (physical / logical). NOTE: sessions recorded -with capture >=0.5.4 persist ``pixel_ratio`` on the recording model itself, so -scaling is always correct for them. Older 0.5.x sessions carry it only when -the recorder wrote it into the recording ``config`` JSON; absent that it -defaults to 1.0 and coordinates pass through unscaled — on such a legacy HiDPI -session click coordinates would be under-scaled, an honest limitation of the -old metadata that this adapter cannot recover from pixels alone. +Coordinate spaces: legacy full-screen capture mouse coordinates are in +*logical points* while captured frames are *physical pixels*. openadapt-flow +requires event coordinates in the same pixel space as the frames, so legacy +points are scaled by ``CaptureSession.pixel_ratio`` (physical / logical). +NOTE: sessions recorded with capture >=0.5.4 persist ``pixel_ratio`` on the +recording model itself, so scaling is always correct for them. Older 0.5.x +sessions carry it only when the recorder wrote it into the recording ``config`` +JSON; absent that it defaults to 1.0 and coordinates pass through unscaled — +on such a legacy HiDPI session click coordinates would be under-scaled, an +honest limitation of the old metadata that this adapter cannot recover from +pixels alone. + +Current full-screen sessions capture MSS's combined virtual desktop and +translate global input into that frame at source. Their +``CaptureSession.desktop_capture["coordinate_space"]`` is +``"virtual_desktop_pixels"``. The retained origin, viewport, and privacy-safe +monitor rectangles let this adapter validate the combined coordinate space; +it does NOT apply ``pixel_ratio`` again. This supports secondary displays and +negative virtual-desktop origins without double-scaling input. Window-scoped sessions (capture's window recording mode, capture PR #30). A session recorded with ``Recorder(window=...)`` is scoped to ONE window: frames @@ -79,14 +88,15 @@ pixels, and rescaling them would double-scale every click (the exact silent-mis-conversion this detection exists to prevent); * takes frames as-is (already the client-window viewport); - * validates the static viewport and bounds timeline, refuses a mid-session - resize (capture media and Flow recordings each have one fixed viewport), - verifies extracted frame sizes, and screens every mouse action against - that pixel space: window capture records out-of-window input at - out-of-range coordinates instead of clamping, and such an action targeted - a DIFFERENT window, so conversion refuses loudly (dropping it would - silently lose a demonstrated action; keeping it would compile a - wrong-target step); + * validates the fixed output viewport and bounds timeline. A source window + can resize: Capture scales the complete source frame to fit and letterboxes + it into the fixed output viewport, then maps actions into that same output + pixel space. The adapter validates this normalization metadata, verifies + extracted frame sizes, and screens every mouse action against that pixel + space. Window capture records out-of-window input at out-of-range + coordinates instead of clamping, and such an action targeted a DIFFERENT + window, so conversion refuses loudly (dropping it would silently lose a + demonstrated action; keeping it would compile a wrong-target step); * stamps the output ``meta.json`` with the recorded scoping (``window_capture``), but does not infer an execution surface from window scope alone. Live ``record --backend rdp|citrix`` orchestration adds the @@ -156,6 +166,10 @@ # (see openadapt_capture.window_capture). Any other declared space is refused. WINDOW_PIXEL_SPACE = "window_pixels" +# Current full-screen Capture sessions translate global pointer positions into +# MSS monitor zero (the combined virtual-desktop frame) before persistence. +DESKTOP_PIXEL_SPACE = "virtual_desktop_pixels" + # pynput key names (openadapt-capture ``key_name`` / ``.keys``) -> # flow/Playwright names. _KEY_NAME_MAP = { @@ -266,6 +280,35 @@ def _window_capture_meta(session: "CaptureSession") -> Optional[dict[str, Any]]: return None +def _desktop_capture_meta(session: "CaptureSession") -> Optional[dict[str, Any]]: + """Virtual-desktop metadata for a current full-screen session, else None. + + Read Capture's public property when available. The config fallback keeps + this adapter compatible with a session produced by a newer Capture package + when an older compatible public API object is used to read the database. + """ + desktop_capture = getattr(session, "desktop_capture", None) + if desktop_capture is not None: + if not isinstance(desktop_capture, dict): + raise ValueError( + "capture session declares malformed desktop-capture metadata; " + "expected an object or null" + ) + return desktop_capture + recording = getattr(session, "_recording", None) + config = getattr(recording, "config", None) + if isinstance(config, dict): + fallback = config.get("capture_desktop") + if fallback is not None: + if not isinstance(fallback, dict): + raise ValueError( + "capture session config declares malformed desktop-capture " + "metadata; expected capture_desktop to be an object or null" + ) + return fallback + return None + + def _is_viewport(value: Any) -> TypeGuard[Sequence[float]]: """True when ``value`` is an exact positive integer pixel pair.""" return ( @@ -282,28 +325,215 @@ def _is_viewport(value: Any) -> TypeGuard[Sequence[float]]: ) +def _exact_integer(value: Any, field: str, *, positive: bool = False) -> int: + """Return one exact finite JSON integer, with a named validation error.""" + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(value) + or not float(value).is_integer() + or (positive and value <= 0) + ): + qualifier = "positive " if positive else "" + raise ValueError( + f"desktop-capture metadata {field} must be a {qualifier}integer" + ) + return int(value) + + +def _validated_desktop_capture( + desktop_capture: dict[str, Any], +) -> dict[str, Any]: + """Validate and sanitize virtual-desktop coordinate provenance. + + Only numeric geometry is carried into Flow's recording metadata. Display + names, application data, or other unrecognized producer fields never cross + this adapter boundary. + """ + space = desktop_capture.get("coordinate_space") + if space != DESKTOP_PIXEL_SPACE: + raise ValueError( + "desktop capture session declares " + f"coordinate_space={space!r}, which this adapter does not " + f"understand (expected {DESKTOP_PIXEL_SPACE!r}); converting it " + "could silently mis-scale action coordinates" + ) + + origin = desktop_capture.get("origin") + if not isinstance(origin, (list, tuple)) or len(origin) != 2: + raise ValueError( + "desktop-capture metadata origin must be a two-integer pixel pair" + ) + parsed_origin = [ + _exact_integer(origin[0], "origin[0]"), + _exact_integer(origin[1], "origin[1]"), + ] + + viewport = desktop_capture.get("viewport") + if not _is_viewport(viewport): + raise ValueError( + "desktop-capture metadata viewport must be two positive integer pixels" + ) + parsed_viewport = [int(viewport[0]), int(viewport[1])] + + monitor_count = _exact_integer( + desktop_capture.get("monitor_count"), "monitor_count", positive=True + ) + monitors = desktop_capture.get("monitors") + if not isinstance(monitors, (list, tuple)): + raise ValueError("desktop-capture metadata monitors must be an array") + if len(monitors) != monitor_count: + raise ValueError( + "desktop-capture metadata monitor_count does not match the number " + "of monitor rectangles" + ) + + parsed_monitors: list[list[int]] = [] + for index, monitor in enumerate(monitors): + if not isinstance(monitor, (list, tuple)) or len(monitor) != 4: + raise ValueError( + "desktop-capture metadata monitor rectangle " + f"{index} must be [left, top, width, height]" + ) + parsed_monitors.append( + [ + _exact_integer(monitor[0], f"monitors[{index}][0]"), + _exact_integer(monitor[1], f"monitors[{index}][1]"), + _exact_integer(monitor[2], f"monitors[{index}][2]", positive=True), + _exact_integer(monitor[3], f"monitors[{index}][3]", positive=True), + ] + ) + + left, top = parsed_origin + width, height = parsed_viewport + right = left + width + bottom = top + height + for index, (monitor_left, monitor_top, monitor_width, monitor_height) in enumerate( + parsed_monitors + ): + if not ( + left <= monitor_left + and top <= monitor_top + and monitor_left + monitor_width <= right + and monitor_top + monitor_height <= bottom + ): + raise ValueError( + "desktop-capture metadata monitor rectangle " + f"{index} falls outside the declared virtual-desktop viewport" + ) + + topology_bounds = [ + min(monitor[0] for monitor in parsed_monitors), + min(monitor[1] for monitor in parsed_monitors), + max(monitor[0] + monitor[2] for monitor in parsed_monitors), + max(monitor[1] + monitor[3] for monitor in parsed_monitors), + ] + if topology_bounds != [left, top, right, bottom]: + raise ValueError( + "desktop-capture metadata monitor rectangles do not span the " + "declared virtual-desktop origin and viewport" + ) + + return { + "coordinate_space": DESKTOP_PIXEL_SPACE, + "origin": parsed_origin, + "viewport": parsed_viewport, + "monitor_count": monitor_count, + "monitors": parsed_monitors, + } + + +def _validate_window_normalization( + state: dict[str, Any], output_viewport: tuple[int, int] +) -> None: + """Validate optional resize-normalization metadata from current Capture. + + Older window sessions do not have these fields. Current sessions retain all + three so a changed native source viewport remains auditable while actions + and frames stay in one fixed, letterboxed output viewport. + """ + fields = ("source_viewport", "content_rect", "fit_scale") + present = [field in state for field in fields] + if not any(present): + return + if not all(present): + raise ValueError( + "window-scoped capture contains incomplete resize-normalization " + "metadata; source_viewport, content_rect, and fit_scale must occur " + "together" + ) + + source_viewport = state["source_viewport"] + if not _is_viewport(source_viewport): + raise ValueError("window-scoped capture contains an invalid source_viewport") + content_rect = state["content_rect"] + if not isinstance(content_rect, (list, tuple)) or len(content_rect) != 4: + raise ValueError( + "window-scoped capture content_rect must be [left, top, width, height]" + ) + try: + rect = tuple( + _exact_integer(value, f"content_rect[{index}]", positive=index >= 2) + for index, value in enumerate(content_rect) + ) + except ValueError as exc: + raise ValueError( + "window-scoped capture contains an invalid content_rect" + ) from exc + rect_left, rect_top, rect_width, rect_height = rect + output_width, output_height = output_viewport + if ( + rect_left < 0 + or rect_top < 0 + or rect_left + rect_width > output_width + or rect_top + rect_height > output_height + ): + raise ValueError( + "window-scoped capture content_rect falls outside the fixed output viewport" + ) + fit_scale = state["fit_scale"] + if ( + not isinstance(fit_scale, (int, float)) + or isinstance(fit_scale, bool) + or not math.isfinite(fit_scale) + or fit_scale <= 0 + ): + raise ValueError("window-scoped capture fit_scale must be finite and positive") + + source_width, source_height = int(source_viewport[0]), int(source_viewport[1]) + expected_scale = min(output_width / source_width, output_height / source_height) + expected_width = max(1, min(output_width, round(source_width * expected_scale))) + expected_height = max(1, min(output_height, round(source_height * expected_scale))) + expected_rect = ( + (output_width - expected_width) // 2, + (output_height - expected_height) // 2, + expected_width, + expected_height, + ) + if not math.isclose(float(fit_scale), expected_scale) or rect != expected_rect: + raise ValueError( + "window-scoped capture resize-normalization metadata does not match " + "the declared source and fixed output viewports" + ) + + def _window_viewport_timeline( session: "CaptureSession", window_capture: dict[str, Any] ) -> list[tuple[float, tuple[int, int]]]: """Time-ordered ``(timestamp, (width, height))`` of the captured frame size. - Starts with the static viewport captured before the input listeners start, - then validates the recording's bounds-timeline window events. Capture's - window mode appends one whenever the resolved window's bounds/title change, - with the captured frame's pixel size in ``state["viewport"]``. Read via a public - ``session.window_events`` accessor when the installed capture exposes one, - else via the session's underlying recording model (same rows). - - Flow recordings and capture's frame timeline each have one fixed viewport. - Capture currently skips frames after a window resize because they no - longer match the stream size. Therefore a timeline viewport change cannot - be represented faithfully and is refused instead of pairing coordinates in - the new pixel space with a stale frame in the old pixel space. + Starts with the fixed output viewport captured before the input listeners + start, then validates the recording's bounds-timeline window events. + Capture appends one whenever the resolved window's bounds/title change. + ``state["viewport"]`` remains the fixed encoded frame size. Current Capture + also retains the changing ``source_viewport`` and its scale-to-fit, + letterbox mapping. Read through a public ``session.window_events`` accessor + when available, else through the session's underlying recording model. Raises: ValueError: When the initial viewport is absent or malformed, a - window-capture timeline row is malformed, or the window changes - size during the recording. + window-capture timeline row is malformed, or the encoded output + viewport changes during the recording. """ initial = window_capture.get("viewport") if not _is_viewport(initial): @@ -315,6 +545,7 @@ def _window_viewport_timeline( "window viewport" ) initial_size = (int(initial[0]), int(initial[1])) + _validate_window_normalization(window_capture, initial_size) # The snapshot is taken after capture's initial frame and before input # listeners start, so it is the only safe viewport for an action whose # timestamp precedes the first persisted timeline row. @@ -357,13 +588,12 @@ def _window_viewport_timeline( size = (int(viewport[0]), int(viewport[1])) if size != initial_size: raise ValueError( - "window-scoped capture changed viewport from " + "window-scoped capture changed its fixed output viewport from " f"{initial_size[0]}x{initial_size[1]} to {size[0]}x{size[1]}; " - "the current capture video and Flow recording formats each " - "have one fixed viewport, so conversion could associate " - "new-space coordinates with a stale old-size frame — " - "re-record without resizing the target window" + "Capture media and Flow recordings each require one encoded " + "viewport, so conversion cannot prove the coordinate space" ) + _validate_window_normalization(state, initial_size) entries.append((parsed_ts, size)) entries.sort(key=lambda entry: entry[0]) return entries @@ -372,8 +602,11 @@ def _window_viewport_timeline( def _reject_out_of_window( actions: "list[Action]", timeline: list[tuple[float, tuple[int, int]]], + *, + scope: str = "window-scoped", + boundary_error: str = "out-of-window input", ) -> None: - """Refuse loudly on any mouse action outside the captured window. + """Refuse loudly on a mouse action outside a captured pixel viewport. Window-scoped recording translates GLOBAL input into the captured frame's pixel space *without clamping*, so input aimed at another window (or the @@ -390,7 +623,7 @@ def _reject_out_of_window( x, y = action.x, action.y if x is None or y is None: raise ValueError( - f"window-scoped {action.type} carries no complete pointer " + f"{scope} {action.type} carries no complete pointer " "coordinates; its target window cannot be verified" ) try: @@ -399,12 +632,12 @@ def _reject_out_of_window( ts = float(action.timestamp) except (TypeError, ValueError) as exc: raise ValueError( - f"window-scoped {action.type} carries non-numeric pointer " + f"{scope} {action.type} carries non-numeric pointer " "coordinates or timestamp; its target window cannot be verified" ) from exc if not all(math.isfinite(value) for value in (parsed_x, parsed_y, ts)): raise ValueError( - f"window-scoped {action.type} carries non-finite pointer " + f"{scope} {action.type} carries non-finite pointer " "coordinates or timestamp; its target window cannot be verified" ) points = [ @@ -421,12 +654,10 @@ def _reject_out_of_window( parsed_dy = float(dy) except (TypeError, ValueError) as exc: raise ValueError( - "window-scoped mouse.drag carries a non-numeric destination" + f"{scope} mouse.drag carries a non-numeric destination" ) from exc if not all(math.isfinite(value) for value in (parsed_dx, parsed_dy)): - raise ValueError( - "window-scoped mouse.drag carries a non-finite destination" - ) + raise ValueError(f"{scope} mouse.drag carries a non-finite destination") points.append((parsed_x + parsed_dx, parsed_y + parsed_dy, "destination")) active_size: Optional[tuple[int, int]] = None for entry_ts, size in timeline: @@ -436,7 +667,7 @@ def _reject_out_of_window( break if active_size is None: raise ValueError( - f"window-scoped {action.type} at t={ts:.3f} precedes every " + f"{scope} {action.type} at t={ts:.3f} precedes every " "known viewport sample; its coordinate space cannot be " "verified safely" ) @@ -452,14 +683,13 @@ def _reject_out_of_window( and 0 <= emitted_y < height ): raise ValueError( - f"out-of-window input: {action.type}{point_suffix} at " + f"{boundary_error}: {action.type}{point_suffix} at " f"({point_x:.3f}, {point_y:.3f}) " - f"(t={ts:.3f}) falls outside the captured window viewport " + f"(t={ts:.3f}) falls outside the captured {scope} viewport " f"{width}x{height}, or rounds outside it as " - f"({emitted_x}, {emitted_y}); the demonstrated action targeted " - "a different window or the desktop, so converting it would " - "compile a wrong-target step — re-record keeping all input " - "inside the captured window" + f"({emitted_x}, {emitted_y}); it has no corresponding " + "captured pixel, so converting it would compile a " + "wrong-target step" ) @@ -836,6 +1066,12 @@ def convert_capture( an RDP or Citrix surface. A window alone does not prove that the captured surface is remote. + A current FULL-SCREEN session with ``CaptureSession.desktop_capture`` is + also converted in its already-normalized frame pixel space. Its validated, + privacy-safe virtual-desktop geometry is retained as ``desktop_capture`` + provenance. Legacy full-screen sessions have neither scope marker and keep + the historical ``pixel_ratio`` conversion. + Args: capture_dir: An openadapt-capture session directory (contains ``recording.db`` and frame media readable through @@ -869,9 +1105,12 @@ def convert_capture( same value, or a click whose before frame is missing from the captured frame timeline. Also, for a window-scoped session: on an out-of-window action, an unknown declared coordinate space, - malformed selector/viewport metadata, a mid-recording resize, or - an extracted frame whose dimensions disagree with the recorded - viewport — refusing loudly instead of mis-converting. + malformed selector/viewport metadata, a changed encoded output + viewport, or an extracted frame whose dimensions disagree with the + recorded viewport. For a current full-screen session: on malformed + or conflicting desktop topology, an unknown coordinate space, an + out-of-desktop action, or a frame-size disagreement. These cases + refuse loudly instead of mis-converting. """ CaptureSession = _require_capture() capture_dir = Path(capture_dir) @@ -890,9 +1129,16 @@ def convert_capture( session = CaptureSession.load(capture_dir) try: window_capture = _window_capture_meta(session) + desktop_capture = _desktop_capture_meta(session) + if window_capture is not None and desktop_capture is not None: + raise ValueError( + "capture session declares both window and desktop capture " + "metadata; its action coordinate space is ambiguous" + ) window_selectors: Optional[ tuple[Optional[str], Optional[str], Optional[str], Optional[str]] ] = None + desktop_provenance: Optional[dict[str, Any]] = None if window_capture is not None: space = window_capture.get("coordinate_space") if space != WINDOW_PIXEL_SPACE: @@ -909,14 +1155,32 @@ def convert_capture( # the captured frame's pixel space. Applying pixel_ratio here # would DOUBLE-scale them; frames are already the window viewport. scale = 1.0 + elif desktop_capture is not None: + desktop_provenance = _validated_desktop_capture(desktop_capture) + # Current full-screen mode translates global input into the + # combined MSS frame before persistence. Applying pixel_ratio here + # would double-scale it, including points on secondary monitors. + scale = 1.0 else: scale = float(session.pixel_ratio or 1.0) actions = list(session.actions(include_moves=False)) - window_viewport: Optional[tuple[int, int]] = None + scoped_viewport: Optional[tuple[int, int]] = None + scope_label: Optional[str] = None if window_capture is not None: timeline = _window_viewport_timeline(session, window_capture) _reject_out_of_window(actions, timeline) - window_viewport = timeline[0][1] + scoped_viewport = timeline[0][1] + scope_label = "window-scoped" + elif desktop_provenance is not None: + desktop_viewport = desktop_provenance["viewport"] + scoped_viewport = (desktop_viewport[0], desktop_viewport[1]) + _reject_out_of_window( + actions, + [(float("-inf"), scoped_viewport)], + scope="virtual-desktop", + boundary_error="out-of-desktop input", + ) + scope_label = "virtual-desktop" events = _flow_events( actions, scale, @@ -945,14 +1209,15 @@ def convert_capture( t_after = min(t_after, float(events[i + 1]["_ts"])) after_img = session.get_frame_at(t_after, tolerance=FRAME_TOLERANCE_S) - if window_viewport is not None: + if scoped_viewport is not None: + assert scope_label is not None for label, image in (("before", before_img), ("after", after_img)): - if image is not None and image.size != window_viewport: + if image is not None and image.size != scoped_viewport: raise ValueError( - f"window-scoped {label} frame for event {i} is " + f"{scope_label} {label} frame for event {i} is " f"{image.width}x{image.height}, but the recorded " - "window viewport is " - f"{window_viewport[0]}x{window_viewport[1]}; " + "viewport is " + f"{scoped_viewport[0]}x{scoped_viewport[1]}; " "coordinates and visual evidence are not in one " "verified pixel space" ) @@ -1017,6 +1282,8 @@ def convert_capture( meta["window_capture"]["resolved_pid"] = resolved_pid if resolved_window_id is not None: meta["window_capture"]["resolved_window_id"] = resolved_window_id + elif desktop_provenance is not None: + meta["desktop_capture"] = desktop_provenance (out_dir / "meta.json").write_text(json.dumps(meta, indent=2)) return out_dir finally: diff --git a/tests/test_capture_adapter.py b/tests/test_capture_adapter.py index a44fd6ac..2a77d0e5 100644 --- a/tests/test_capture_adapter.py +++ b/tests/test_capture_adapter.py @@ -854,11 +854,7 @@ def test_window_mode_out_of_window_scroll_rejected(tmp_path: Path) -> None: def test_window_mode_bounds_timeline_honored(tmp_path: Path) -> None: - """A mid-recording resize is refused even when actions remain in bounds. - - Capture's fixed-size MP4 skips resized frames and Flow has one viewport. - Accepting the resize could pair new-space coordinates with an old frame. - """ + """A source resize is valid when Capture normalizes to one output viewport.""" x, y = 100.0, 100.0 # deliberately inside both 1280x800 and 640x400 rows = _click_rows(T0 + 1.0, x, y) + _click_rows(T0 + 2.0, x, y) small = (640, 400) @@ -875,6 +871,9 @@ def test_window_mode_bounds_timeline_honored(tmp_path: Path) -> None: "window_capture": True, "owner": WINDOW_OWNER, "viewport": list(FRAME_SIZE), + "source_viewport": list(FRAME_SIZE), + "content_rect": [0, 0, *FRAME_SIZE], + "fit_scale": 1.0, }, }, { @@ -888,10 +887,54 @@ def test_window_mode_bounds_timeline_honored(tmp_path: Path) -> None: "state": { "window_capture": True, "owner": WINDOW_OWNER, - "viewport": list(small), + # The native source changed, but every encoded frame and every + # translated action remains in the first frame's output space. + "viewport": list(FRAME_SIZE), + "source_viewport": list(small), + "content_rect": [0, 0, *FRAME_SIZE], + "fit_scale": 2.0, }, }, ] + config = window_capture_config( + source_viewport=list(FRAME_SIZE), + content_rect=[0, 0, *FRAME_SIZE], + fit_scale=1.0, + ) + capture_dir = make_capture( + tmp_path, + rows, + screens=app_screens()[:1], + config=config, + window_event_rows=window_event_rows, + ) + recording_dir = tmp_path / "recording" + convert_capture(capture_dir, recording_dir) + assert [(event["x"], event["y"]) for event in events_of(recording_dir)] == [ + (100, 100), + (100, 100), + ] + + +def test_window_mode_changed_output_viewport_rejected(tmp_path: Path) -> None: + """Only the source may resize; the encoded output viewport stays fixed.""" + rows = _click_rows(T0 + 1.0, 100.0, 100.0) + window_event_rows = [ + { + "timestamp": T0, + "title": WINDOW_TITLE, + "left": 100, + "top": 50, + "width": FRAME_SIZE[0] // 2, + "height": FRAME_SIZE[1] // 2, + "window_id": "42", + "state": { + "window_capture": True, + "owner": WINDOW_OWNER, + "viewport": [640, 400], + }, + } + ] capture_dir = make_capture( tmp_path, rows, @@ -899,7 +942,24 @@ def test_window_mode_bounds_timeline_honored(tmp_path: Path) -> None: config=window_capture_config(), window_event_rows=window_event_rows, ) - with pytest.raises(ValueError, match=r"changed viewport.*640x400"): + with pytest.raises(ValueError, match=r"fixed output viewport.*640x400"): + convert_capture(capture_dir, tmp_path / "recording") + + +def test_window_mode_invalid_resize_normalization_rejected(tmp_path: Path) -> None: + """A letterbox mapping must agree with both source and output viewports.""" + config = window_capture_config( + source_viewport=[800, 1200], + content_rect=[0, 0, *FRAME_SIZE], + fit_scale=1.0, + ) + capture_dir = make_capture( + tmp_path, + _click_rows(T0 + 1.0, 100.0, 100.0), + screens=app_screens()[:1], + config=config, + ) + with pytest.raises(ValueError, match="resize-normalization metadata"): convert_capture(capture_dir, tmp_path / "recording") @@ -1076,3 +1136,144 @@ def test_window_mode_frame_size_must_match_recorded_viewport( ) with pytest.raises(ValueError, match=r"frame.*1280x800.*viewport.*640x400"): convert_capture(capture_dir, tmp_path / "recording") + + +# -- current full-screen sessions (combined virtual-desktop pixels) ----------- + + +DESKTOP_ORIGIN = (-640, 0) +DESKTOP_MONITORS = [[-640, 0, 640, 800], [0, 0, 640, 800]] + + +def desktop_capture_config(**overrides) -> dict: + """Recording config for a two-display virtual desktop with negative x.""" + capture_desktop = { + "coordinate_space": "virtual_desktop_pixels", + "origin": list(DESKTOP_ORIGIN), + "viewport": list(FRAME_SIZE), + "monitor_count": 2, + "monitors": DESKTOP_MONITORS, + # The adapter must retain only the privacy-safe geometry allowlist. + "producer_extension": "not retained", + } + capture_desktop.update(overrides) + return {"pixel_ratio": PIXEL_RATIO, "capture_desktop": capture_desktop} + + +def test_desktop_mode_uses_combined_frame_coordinates_and_provenance( + tmp_path: Path, +) -> None: + """Translated multi-monitor pixels pass through without a HiDPI rescale.""" + # Global x=100 on the right display becomes x=740 in a frame whose origin + # is -640. Capture performs that translation before persistence. + translated = (740.0, 400.0) + rows = _click_rows(T0 + 1.0, *translated) + capture_dir = make_capture( + tmp_path, + rows, + screens=app_screens()[:1], + config=desktop_capture_config(), + ) + recording_dir = tmp_path / "recording" + convert_capture(capture_dir, recording_dir) + + click = events_of(recording_dir)[0] + assert (click["x"], click["y"]) == (740, 400) + meta = json.loads((recording_dir / "meta.json").read_text()) + assert meta["desktop_capture"] == { + "coordinate_space": "virtual_desktop_pixels", + "origin": [-640, 0], + "viewport": list(FRAME_SIZE), + "monitor_count": 2, + "monitors": DESKTOP_MONITORS, + } + assert "window_capture" not in meta + assert "producer_extension" not in meta["desktop_capture"] + + +def test_window_and_desktop_scope_are_mutually_exclusive(tmp_path: Path) -> None: + config = desktop_capture_config() + config["capture_window"] = window_capture_config()["capture_window"] + capture_dir = make_capture( + tmp_path, + _click_rows(T0 + 1.0, 100.0, 100.0), + screens=app_screens()[:1], + config=config, + ) + with pytest.raises(ValueError, match="both window and desktop"): + convert_capture(capture_dir, tmp_path / "recording") + + +def test_desktop_mode_out_of_frame_action_rejected(tmp_path: Path) -> None: + rows = _click_rows(T0 + 1.0, float(FRAME_SIZE[0] + 1), 100.0) + capture_dir = make_capture( + tmp_path, + rows, + screens=app_screens()[:1], + config=desktop_capture_config(), + ) + with pytest.raises(ValueError, match="out-of-desktop input"): + convert_capture(capture_dir, tmp_path / "recording") + + +def test_desktop_mode_unknown_coordinate_space_rejected(tmp_path: Path) -> None: + config = desktop_capture_config(coordinate_space="global_screen_points") + capture_dir = make_capture( + tmp_path, + _click_rows(T0 + 1.0, 100.0, 100.0), + screens=app_screens()[:1], + config=config, + ) + with pytest.raises(ValueError, match="coordinate_space='global_screen_points'"): + convert_capture(capture_dir, tmp_path / "recording") + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"origin": [True, 0]}, "origin"), + ({"viewport": [1280.5, 800]}, "viewport"), + ({"monitor_count": 1}, "monitor_count"), + ( + {"monitors": [[-641, 0, 641, 800], [0, 0, 640, 800]]}, + "outside", + ), + ( + {"monitors": [[-600, 0, 600, 800], [0, 0, 640, 800]]}, + "do not span", + ), + ], +) +def test_desktop_mode_malformed_topology_rejected( + tmp_path: Path, overrides: dict, message: str +) -> None: + config = desktop_capture_config(**overrides) + capture_dir = make_capture( + tmp_path, + _click_rows(T0 + 1.0, 100.0, 100.0), + screens=app_screens()[:1], + config=config, + ) + with pytest.raises(ValueError, match=message): + convert_capture(capture_dir, tmp_path / "recording") + + +def test_desktop_mode_frame_size_must_match_declared_viewport( + tmp_path: Path, +) -> None: + config = desktop_capture_config( + origin=[0, 0], + viewport=[640, 400], + monitor_count=1, + monitors=[[0, 0, 640, 400]], + ) + capture_dir = make_capture( + tmp_path, + _click_rows(T0 + 1.0, 100.0, 100.0), + screens=app_screens()[:1], + config=config, + ) + with pytest.raises( + ValueError, match=r"virtual-desktop.*1280x800.*viewport.*640x400" + ): + convert_capture(capture_dir, tmp_path / "recording") diff --git a/tests/test_release_ci_gate.py b/tests/test_release_ci_gate.py index 0dc36e26..28f43a62 100644 --- a/tests/test_release_ci_gate.py +++ b/tests/test_release_ci_gate.py @@ -124,7 +124,9 @@ def test_production_gate_rejects_missing_clean_machine_run() -> None: def test_production_gate_rejects_partial_clean_machine_matrix() -> None: - with pytest.raises(QualificationError, match="clean-machine job set/count mismatch"): + with pytest.raises( + QualificationError, match="clean-machine job set/count mismatch" + ): _require_production(FakeGitHub(clean_jobs=_clean_jobs()[:-1])) From 312e0e1fbb1578f1d7776c18b8642b1895c442b1 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 19 Aug 2026 19:47:09 -0400 Subject: [PATCH 3/4] chore: regenerate the artifact inventory after the rebase The rebase onto main conflicted only on the generated inventory. Resolved by taking main's side and regenerating. One hash moves, for .github/workflows/quickstart-lifecycle.yml, which this branch edits. Co-Authored-By: Claude Opus 5 --- public-artifacts.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public-artifacts.json b/public-artifacts.json index 4d9f9736..855d7e63 100644 --- a/public-artifacts.json +++ b/public-artifacts.json @@ -113,7 +113,7 @@ }, { "path": ".github/workflows/quickstart-lifecycle.yml", - "sha256": "bb8269715c3023f5f42beb3b53e3744b8c03bcbd43f19e989a5f318063d290d6" + "sha256": "8eae9755b0ba287c708dd078b996f4b139e949ae375cfc5bb7a6e0f441b38ece" }, { "path": ".github/workflows/release-health.yml", From d98455b560956473ad72bfc40d01359d7561dd98 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 19 Aug 2026 20:32:38 -0400 Subject: [PATCH 4/4] build: refresh the lock after the rebase The rebase onto main left uv.lock stale against this branch's pyproject change, so 'uv lock --locked' failed in the lint job. Regenerating removes opencv-python-headless, which is precisely this branch's purpose: exactly one cv2 provider, opencv-python. Co-Authored-By: Claude Opus 5 --- uv.lock | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/uv.lock b/uv.lock index 3ca6433c..45366430 100644 --- a/uv.lock +++ b/uv.lock @@ -2143,7 +2143,7 @@ dependencies = [ { name = "httpx" }, { name = "idna" }, { name = "numpy" }, - { name = "opencv-python-headless" }, + { name = "opencv-python" }, { name = "pillow" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -2248,7 +2248,7 @@ requires-dist = [ { name = "openadapt-types", marker = "extra == 'console'", specifier = ">=0.10.0,<0.11.0" }, { name = "openadapt-types", marker = "extra == 'dev'", specifier = ">=0.10.0,<0.11.0" }, { name = "openadapt-types", marker = "extra == 'interop'", specifier = ">=0.10.0,<0.11.0" }, - { name = "opencv-python-headless", specifier = ">=4.9" }, + { name = "opencv-python", specifier = ">=4.9" }, { name = "pillow", specifier = ">=10.0" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.44" }, { name = "playwright", marker = "extra == 'dev'", specifier = ">=1.44" }, @@ -2361,25 +2361,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] -[[package]] -name = "opencv-python-headless" -version = "5.0.0.93" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, - { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, - { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, - { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, -] - [[package]] name = "oscrypto" version = "1.3.0"