diff --git a/.github/workflows/custodian-audit.yml b/.github/workflows/custodian-audit.yml index 155fde8..9bdcf6c 100644 --- a/.github/workflows/custodian-audit.yml +++ b/.github/workflows/custodian-audit.yml @@ -29,35 +29,33 @@ jobs: fi - name: Materialize boundary artifact file + # Decode the boundary disclosure artifact from the base64 CONTENT secret + # REPOGRAPH_BOUNDARY_ARTIFACT_B64. The older *_FILE secret held a filesystem + # path that cannot resolve on a CI runner (the artifact lives in the private + # repo on the dev machine), so this step used to fail and the audit never ran. + # Graceful: if the secret is absent, skip — custodian's B2 reports the + # missing-artifact requirement rather than this step hard-failing. env: - REPOGRAPH_BOUNDARY_ARTIFACT_SOURCE: ${{ secrets.REPOGRAPH_BOUNDARY_ARTIFACT_FILE }} + REPOGRAPH_BOUNDARY_ARTIFACT_B64: ${{ secrets.REPOGRAPH_BOUNDARY_ARTIFACT_B64 }} run: | - if [ -z "${REPOGRAPH_BOUNDARY_ARTIFACT_SOURCE:-}" ]; then - echo "Missing REPOGRAPH_BOUNDARY_ARTIFACT_FILE secret" >&2 - exit 1 + if [ -z "${REPOGRAPH_BOUNDARY_ARTIFACT_B64:-}" ]; then + echo "REPOGRAPH_BOUNDARY_ARTIFACT_B64 not set — skipping (B2 flags if required)." + exit 0 fi - tmp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/repograph-boundary-XXXXXX.json")" - python - "$REPOGRAPH_BOUNDARY_ARTIFACT_SOURCE" "$tmp_file" <<'PY' + dest="$(mktemp "${RUNNER_TEMP:-/tmp}/repograph-boundary-XXXXXX.json")" + printf '%s' "$REPOGRAPH_BOUNDARY_ARTIFACT_B64" | base64 -d > "$dest" + python - "$dest" <<'PY' import json - import shutil import sys - import urllib.request from pathlib import Path - source = sys.argv[1] - dest = Path(sys.argv[2]) - src_path = Path(source) - if src_path.is_file(): - shutil.copyfile(src_path, dest) - elif source.startswith(("http://", "https://")): - with urllib.request.urlopen(source) as response, dest.open("wb") as fh: - shutil.copyfileobj(response, fh) - else: - raise SystemExit(f"Unsupported boundary artifact source: {source}") - data = json.loads(dest.read_text(encoding="utf-8")) + data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) print(f"boundary_provenance={data.get('source_graph_id')}@{data.get('source_ref_or_commit')}") PY - echo "REPOGRAPH_BOUNDARY_ARTIFACT_FILE=$tmp_file" >> "$GITHUB_ENV" + echo "REPOGRAPH_BOUNDARY_ARTIFACT_FILE=$dest" >> "$GITHUB_ENV" - name: Run Custodian audit run: | + # .custodian/config.yaml flags an unset core.hooksPath (W2); wire it + # like a developer checkout would so the audit reflects real findings. + git config core.hooksPath .hooks custodian-multi --repos . --fail-on-findings --no-color diff --git a/src/core_runner/contracts/__init__.py b/src/core_runner/contracts/__init__.py index c894b80..71a081b 100644 --- a/src/core_runner/contracts/__init__.py +++ b/src/core_runner/contracts/__init__.py @@ -10,6 +10,7 @@ ``pending | running | succeeded | failed | timed_out | cancelled | rejected``. """ + from core_runner.contracts.invocation import RuntimeInvocation from core_runner.contracts.result import ArtifactDescriptor, RuntimeResult diff --git a/src/core_runner/contracts/invocation.py b/src/core_runner/contracts/invocation.py index 6ec6050..db45ee8 100644 --- a/src/core_runner/contracts/invocation.py +++ b/src/core_runner/contracts/invocation.py @@ -5,6 +5,7 @@ import RuntimeInvocation`` without depending on the RxP package directly. """ + from rxp.contracts import RuntimeInvocation __all__ = ["RuntimeInvocation"] diff --git a/src/core_runner/contracts/result.py b/src/core_runner/contracts/result.py index 5759672..bbaee7d 100644 --- a/src/core_runner/contracts/result.py +++ b/src/core_runner/contracts/result.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """CoreRunner returns the canonical RxP RuntimeResult contract.""" + from rxp.contracts import ArtifactDescriptor, RuntimeResult __all__ = ["RuntimeResult", "ArtifactDescriptor"] diff --git a/src/core_runner/process.py b/src/core_runner/process.py index 943fc0c..f141fb2 100644 --- a/src/core_runner/process.py +++ b/src/core_runner/process.py @@ -12,6 +12,7 @@ - Transient SIGTERM handler — child group is killed if the Python supervisor is itself killed (OOM killer, supervisor stop) """ + from __future__ import annotations import os diff --git a/src/core_runner/runners/async_http_runner.py b/src/core_runner/runners/async_http_runner.py index 70108ea..8ee89d0 100644 --- a/src/core_runner/runners/async_http_runner.py +++ b/src/core_runner/runners/async_http_runner.py @@ -57,6 +57,7 @@ Each call uses a short-lived ``httpx.Client``; no global state. """ + from __future__ import annotations import json @@ -97,8 +98,7 @@ def __init__( ) -> None: if httpx is None and client is None: raise ImportError( - "AsyncHttpRunner requires httpx. Install with " - "`pip install core-runner[http]`" + "AsyncHttpRunner requires httpx. Install with `pip install core-runner[http]`" ) self._follow_redirects = follow_redirects self._verify = verify @@ -137,12 +137,11 @@ def run(self, invocation: RuntimeInvocation) -> RuntimeResult: pending_raw = meta.get("http.poll_pending_codes") or "" try: - pending_codes = tuple( - int(s.strip()) for s in pending_raw.split(",") if s.strip() - ) + pending_codes = tuple(int(s.strip()) for s in pending_raw.split(",") if s.strip()) except ValueError: return _rejected( - invocation, started, + invocation, + started, "http.poll_pending_codes must be comma-separated integers", ) @@ -187,18 +186,22 @@ def run(self, invocation: RuntimeInvocation) -> RuntimeResult: # - server acknowledged async dispatch (status field absent # or non-terminal, e.g. Archon's {accepted, status:"started"}) if _is_synchronous_terminal( - kickoff_resp, poll_status_path, terminal_states, + kickoff_resp, + poll_status_path, + terminal_states, ): return _terminal_from_kickoff( - invocation, started, kickoff_resp, - success_states, poll_status_path, + invocation, + started, + kickoff_resp, + success_states, + poll_status_path, ) # Fall through to poll loop — kickoff was an ack, not a result. elif kickoff_resp.status_code != 202: preview = kickoff_resp.text[:200] if kickoff_resp.text else "" msg = ( - f"kickoff expected 202 (or 200), got HTTP " - f"{kickoff_resp.status_code}: {preview}" + f"kickoff expected 202 (or 200), got HTTP {kickoff_resp.status_code}: {preview}" ).strip() return _failed(invocation, started, msg) @@ -208,7 +211,8 @@ def run(self, invocation: RuntimeInvocation) -> RuntimeResult: run_id_path = meta.get("http.poll_run_id_path") if not run_id_path: return _rejected( - invocation, started, + invocation, + started, "poll_url_template contains {run_id} but no poll_run_id_path provided", ) try: @@ -228,7 +232,9 @@ def run(self, invocation: RuntimeInvocation) -> RuntimeResult: while True: if _deadline_exceeded(deadline_monotonic): return _timed_out( - invocation, started, timeout, + invocation, + started, + timeout, TimeoutError("poll loop deadline exceeded"), "poll", ) @@ -249,7 +255,8 @@ def run(self, invocation: RuntimeInvocation) -> RuntimeResult: continue preview = poll_resp.text[:200] if poll_resp.text else "" return _failed( - invocation, started, + invocation, + started, f"poll expected HTTP 200, got {poll_resp.status_code}: {preview}".strip(), ) try: @@ -260,8 +267,7 @@ def run(self, invocation: RuntimeInvocation) -> RuntimeResult: status = _extract_path(poll_payload, poll_status_path) if status is None: msg = ( - f"poll response has no field at path " - f"{poll_status_path!r}: {poll_payload!r}" + f"poll response has no field at path {poll_status_path!r}: {poll_payload!r}" ) return _failed(invocation, started, msg) status_str = str(status) @@ -280,8 +286,7 @@ def run(self, invocation: RuntimeInvocation) -> RuntimeResult: stderr_path=None, artifacts=[], error_summary=( - None if success - else f"backend reported terminal status: {status_str}" + None if success else f"backend reported terminal status: {status_str}" ), ) @@ -454,9 +459,7 @@ def _timed_out( phase: str, ) -> RuntimeResult: note = ( - f"{phase} exceeded timeout of {timeout}s: {exc}" - if timeout - else f"{phase} timed out: {exc}" + f"{phase} exceeded timeout of {timeout}s: {exc}" if timeout else f"{phase} timed out: {exc}" ) return RuntimeResult( invocation_id=invocation.invocation_id, diff --git a/src/core_runner/runners/http_runner.py b/src/core_runner/runners/http_runner.py index b9c591f..cdd2f58 100644 --- a/src/core_runner/runners/http_runner.py +++ b/src/core_runner/runners/http_runner.py @@ -17,6 +17,7 @@ This runner installs no global state. Each ``run`` opens a short-lived ``httpx.Client`` so timeout/cancellation semantics are local to the call. """ + from __future__ import annotations import json @@ -50,8 +51,7 @@ def __init__( ) -> None: if httpx is None and client is None: raise ImportError( - "HttpRunner requires httpx. Install with " - "`pip install core-runner[http]`" + "HttpRunner requires httpx. Install with `pip install core-runner[http]`" ) self._follow_redirects = follow_redirects self._verify = verify diff --git a/src/core_runner/runners/manual_runner.py b/src/core_runner/runners/manual_runner.py index 6168290..98e58de 100644 --- a/src/core_runner/runners/manual_runner.py +++ b/src/core_runner/runners/manual_runner.py @@ -11,6 +11,7 @@ ``HttpRunner`` / ``ContainerRunner`` will cover ``"http"`` / ``"container"`` with concrete implementations. """ + from __future__ import annotations from collections.abc import Callable diff --git a/src/core_runner/runners/subprocess_runner.py b/src/core_runner/runners/subprocess_runner.py index 7d06a25..257fbe0 100644 --- a/src/core_runner/runners/subprocess_runner.py +++ b/src/core_runner/runners/subprocess_runner.py @@ -5,9 +5,9 @@ Provides stdout/stderr capture to files and ArtifactDescriptor production on top of the process-group-safe safe_run() primitive. """ + from __future__ import annotations -import os from datetime import UTC, datetime from pathlib import Path diff --git a/src/core_runner/runtime.py b/src/core_runner/runtime.py index 36fa59b..022acc3 100644 --- a/src/core_runner/runtime.py +++ b/src/core_runner/runtime.py @@ -12,6 +12,7 @@ than raising — same posture as the missing-working-directory check in ``SubprocessRunner``. """ + from __future__ import annotations from datetime import UTC, datetime diff --git a/tests/conftest.py b/tests/conftest.py index 4c12e49..1b5f59e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ Refuses to run unless invoked from inside this project's `.venv` — prevents accidental test runs against the global interpreter. """ + from __future__ import annotations import os diff --git a/tests/runners/test_async_http_runner.py b/tests/runners/test_async_http_runner.py index f35339a..bae366e 100644 --- a/tests/runners/test_async_http_runner.py +++ b/tests/runners/test_async_http_runner.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 ProtocolWarden """Tests for AsyncHttpRunner — kickoff (202) + poll-until-terminal.""" + from __future__ import annotations import httpx @@ -69,11 +70,13 @@ def _make_runner(client: httpx.Client) -> AsyncHttpRunner: class TestHappyPath: def test_kickoff_then_poll_terminal_success(self): - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc123"}), # kickoff - httpx.Response(200, json={"status": "running"}), # poll 1 - httpx.Response(200, json={"status": "completed"}), # poll 2 (terminal) - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc123"}), # kickoff + httpx.Response(200, json={"status": "running"}), # poll 1 + httpx.Response(200, json={"status": "completed"}), # poll 2 (terminal) + ] + ) runner = _make_runner(client) result = runner.run(_invocation()) assert result.status == "succeeded" @@ -82,10 +85,12 @@ def test_kickoff_then_poll_terminal_success(self): assert result.error_summary is None def test_kickoff_then_poll_terminal_failure(self): - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(200, json={"status": "failed"}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(200, json={"status": "failed"}), + ] + ) runner = _make_runner(client) result = runner.run(_invocation()) assert result.status == "failed" @@ -93,30 +98,38 @@ def test_kickoff_then_poll_terminal_failure(self): assert "failed" in (result.error_summary or "") def test_terminal_status_in_alternate_field(self): - client = _scripted_client([ - httpx.Response(202, json={"run": {"id": "abc"}}), - httpx.Response(200, json={"run": {"status": "completed"}}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run": {"id": "abc"}}), + httpx.Response(200, json={"run": {"status": "completed"}}), + ] + ) runner = _make_runner(client) - inv = _invocation(metadata_extra={ - "http.poll_run_id_path": "run.id", - "http.poll_status_path": "run.status", - }) + inv = _invocation( + metadata_extra={ + "http.poll_run_id_path": "run.id", + "http.poll_status_path": "run.status", + } + ) result = runner.run(inv) assert result.status == "succeeded" def test_synchronous_200_response_treated_as_terminal(self): - client = _scripted_client([ - httpx.Response(200, json={"status": "completed"}), - ]) + client = _scripted_client( + [ + httpx.Response(200, json={"status": "completed"}), + ] + ) runner = _make_runner(client) result = runner.run(_invocation()) assert result.status == "succeeded" def test_synchronous_200_with_failure_status(self): - client = _scripted_client([ - httpx.Response(200, json={"status": "failed"}), - ]) + client = _scripted_client( + [ + httpx.Response(200, json={"status": "failed"}), + ] + ) runner = _make_runner(client) result = runner.run(_invocation()) assert result.status == "failed" @@ -155,43 +168,57 @@ def test_missing_kickoff_url(self): def test_missing_poll_template(self): inv = _invocation() # Construct a fresh invocation lacking the template - inv = RuntimeInvocation(**{ - **inv.model_dump(), - "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_url_template"}, - }) + inv = RuntimeInvocation( + **{ + **inv.model_dump(), + "metadata": { + k: v for k, v in inv.metadata.items() if k != "http.poll_url_template" + }, + } + ) result = _make_runner(_scripted_client([])).run(inv) assert result.status == "rejected" assert "poll_url_template" in (result.error_summary or "") def test_missing_status_path(self): inv = _invocation() - inv = RuntimeInvocation(**{ - **inv.model_dump(), - "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_status_path"}, - }) + inv = RuntimeInvocation( + **{ + **inv.model_dump(), + "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_status_path"}, + } + ) result = _make_runner(_scripted_client([])).run(inv) assert result.status == "rejected" assert "poll_status_path" in (result.error_summary or "") def test_missing_terminal_states(self): inv = _invocation() - inv = RuntimeInvocation(**{ - **inv.model_dump(), - "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_terminal_states"}, - }) + inv = RuntimeInvocation( + **{ + **inv.model_dump(), + "metadata": { + k: v for k, v in inv.metadata.items() if k != "http.poll_terminal_states" + }, + } + ) result = _make_runner(_scripted_client([])).run(inv) assert result.status == "rejected" assert "poll_terminal_states" in (result.error_summary or "") def test_template_with_run_id_but_no_path(self): inv = _invocation() - inv = RuntimeInvocation(**{ - **inv.model_dump(), - "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_run_id_path"}, - }) - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - ]) + inv = RuntimeInvocation( + **{ + **inv.model_dump(), + "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_run_id_path"}, + } + ) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + ] + ) result = _make_runner(client).run(inv) assert result.status == "rejected" assert "poll_run_id_path" in (result.error_summary or "") @@ -204,25 +231,31 @@ def test_template_with_run_id_but_no_path(self): class TestKickoffErrors: def test_non_202_kickoff_fails(self): - client = _scripted_client([ - httpx.Response(500, text="boom"), - ]) + client = _scripted_client( + [ + httpx.Response(500, text="boom"), + ] + ) result = _make_runner(client).run(_invocation()) assert result.status == "failed" assert "500" in (result.error_summary or "") def test_kickoff_timeout(self): - client = _scripted_client([ - httpx.TimeoutException("kickoff timed out"), - ]) + client = _scripted_client( + [ + httpx.TimeoutException("kickoff timed out"), + ] + ) result = _make_runner(client).run(_invocation()) assert result.status == "failed" assert "kickoff" in (result.error_summary or "") def test_run_id_extraction_failure(self): - client = _scripted_client([ - httpx.Response(202, json={"unexpected_field": "abc"}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"unexpected_field": "abc"}), + ] + ) result = _make_runner(client).run(_invocation()) assert result.status == "failed" assert "no field at path" in (result.error_summary or "") @@ -230,28 +263,34 @@ def test_run_id_extraction_failure(self): class TestPollErrors: def test_poll_non_200_fails(self): - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(503, text="upstream down"), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(503, text="upstream down"), + ] + ) result = _make_runner(client).run(_invocation()) assert result.status == "failed" assert "503" in (result.error_summary or "") def test_poll_timeout(self): - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.TimeoutException("poll timed out"), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.TimeoutException("poll timed out"), + ] + ) result = _make_runner(client).run(_invocation()) assert result.status == "failed" assert "poll" in (result.error_summary or "") def test_status_extraction_failure(self): - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(200, json={"unexpected": "missing status"}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(200, json={"unexpected": "missing status"}), + ] + ) result = _make_runner(client).run(_invocation()) assert result.status == "failed" assert "no field at path" in (result.error_summary or "") @@ -265,12 +304,14 @@ def test_status_extraction_failure(self): class TestPollLoop: def test_sleep_called_between_polls(self): sleeps: list[float] = [] - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(200, json={"status": "running"}), - httpx.Response(200, json={"status": "running"}), - httpx.Response(200, json={"status": "completed"}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(200, json={"status": "running"}), + httpx.Response(200, json={"status": "running"}), + httpx.Response(200, json={"status": "completed"}), + ] + ) runner = AsyncHttpRunner(client=client, sleep=sleeps.append) inv = _invocation(metadata_extra={"http.poll_interval_seconds": "1.5"}) result = runner.run(inv) @@ -280,11 +321,13 @@ def test_sleep_called_between_polls(self): def test_zero_interval_skips_sleep_arg(self): sleeps: list[float] = [] - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(200, json={"status": "running"}), - httpx.Response(200, json={"status": "completed"}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(200, json={"status": "running"}), + httpx.Response(200, json={"status": "completed"}), + ] + ) runner = AsyncHttpRunner(client=client, sleep=sleeps.append) result = runner.run(_invocation()) # poll_interval=0.0 in default metadata assert result.status == "succeeded" @@ -304,50 +347,58 @@ class TestKickoff200Ack: def test_200_with_non_terminal_status_falls_through_to_poll(self): # Kickoff response carries a non-terminal status — runner should poll. - client = _scripted_client([ - httpx.Response(200, json={"accepted": True, "status": "started"}), - httpx.Response(200, json={"status": "completed"}), - ]) + client = _scripted_client( + [ + httpx.Response(200, json={"accepted": True, "status": "started"}), + httpx.Response(200, json={"status": "completed"}), + ] + ) # Use a poll URL without {run_id} template so we don't need run_id # extraction from the kickoff body. - inv = _invocation(metadata_extra={ - "http.poll_url_template": "http://example.test/api/runs/by-conv/abc", - }) + inv = _invocation( + metadata_extra={ + "http.poll_url_template": "http://example.test/api/runs/by-conv/abc", + } + ) # Drop the run-id-path metadata since template has no {run_id}. - inv = RuntimeInvocation(**{ - **inv.model_dump(), - "metadata": { - k: v for k, v in inv.metadata.items() - if k != "http.poll_run_id_path" - }, - }) + inv = RuntimeInvocation( + **{ + **inv.model_dump(), + "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_run_id_path"}, + } + ) result = _make_runner(client).run(inv) assert result.status == "succeeded" def test_200_with_missing_status_field_falls_through_to_poll(self): # Status field absent at poll_status_path — treat as kickoff ack. - client = _scripted_client([ - httpx.Response(200, json={"accepted": True}), - httpx.Response(200, json={"status": "completed"}), - ]) - inv = _invocation(metadata_extra={ - "http.poll_url_template": "http://example.test/api/runs/by-conv/abc", - }) - inv = RuntimeInvocation(**{ - **inv.model_dump(), - "metadata": { - k: v for k, v in inv.metadata.items() - if k != "http.poll_run_id_path" - }, - }) + client = _scripted_client( + [ + httpx.Response(200, json={"accepted": True}), + httpx.Response(200, json={"status": "completed"}), + ] + ) + inv = _invocation( + metadata_extra={ + "http.poll_url_template": "http://example.test/api/runs/by-conv/abc", + } + ) + inv = RuntimeInvocation( + **{ + **inv.model_dump(), + "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_run_id_path"}, + } + ) result = _make_runner(client).run(inv) assert result.status == "succeeded" def test_200_with_terminal_status_still_treated_as_sync_result(self): # Backward compat: 200 + terminal status keeps the existing sync path. - client = _scripted_client([ - httpx.Response(200, json={"status": "completed"}), - ]) + client = _scripted_client( + [ + httpx.Response(200, json={"status": "completed"}), + ] + ) runner = _make_runner(client) result = runner.run(_invocation()) assert result.status == "succeeded" @@ -356,44 +407,51 @@ def test_200_kickoff_then_404_pending_then_terminal(self): # Realistic Archon flow: 200 ack, then by-worker 404 (run not yet # registered) tolerated via http.poll_pending_codes, then 200 with # terminal status. - client = _scripted_client([ - httpx.Response(200, json={"accepted": True, "status": "started"}), - httpx.Response(404, text="not registered yet"), - httpx.Response(404, text="not registered yet"), - httpx.Response(200, json={"run": {"status": "completed"}}), - ]) - inv = _invocation(metadata_extra={ - "http.poll_url_template": "http://example.test/api/runs/by-conv/abc", - "http.poll_status_path": "run.status", - "http.poll_pending_codes": "404", - }) - inv = RuntimeInvocation(**{ - **inv.model_dump(), - "metadata": { - k: v for k, v in inv.metadata.items() - if k != "http.poll_run_id_path" - }, - }) + client = _scripted_client( + [ + httpx.Response(200, json={"accepted": True, "status": "started"}), + httpx.Response(404, text="not registered yet"), + httpx.Response(404, text="not registered yet"), + httpx.Response(200, json={"run": {"status": "completed"}}), + ] + ) + inv = _invocation( + metadata_extra={ + "http.poll_url_template": "http://example.test/api/runs/by-conv/abc", + "http.poll_status_path": "run.status", + "http.poll_pending_codes": "404", + } + ) + inv = RuntimeInvocation( + **{ + **inv.model_dump(), + "metadata": {k: v for k, v in inv.metadata.items() if k != "http.poll_run_id_path"}, + } + ) result = _make_runner(client).run(inv) assert result.status == "succeeded" class TestPollPendingCodes: def test_pending_code_keeps_polling(self): - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(404, text="pending"), - httpx.Response(200, json={"status": "completed"}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(404, text="pending"), + httpx.Response(200, json={"status": "completed"}), + ] + ) inv = _invocation(metadata_extra={"http.poll_pending_codes": "404"}) result = _make_runner(client).run(inv) assert result.status == "succeeded" def test_non_pending_non_200_still_fails(self): - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(503, text="upstream"), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(503, text="upstream"), + ] + ) inv = _invocation(metadata_extra={"http.poll_pending_codes": "404"}) result = _make_runner(client).run(inv) assert result.status == "failed" @@ -408,17 +466,21 @@ def test_pending_codes_must_be_integers(self): def test_pending_code_sleeps_between_retries(self): sleeps: list[float] = [] - client = _scripted_client([ - httpx.Response(202, json={"run_id": "abc"}), - httpx.Response(404, text="pending"), - httpx.Response(404, text="pending"), - httpx.Response(200, json={"status": "completed"}), - ]) + client = _scripted_client( + [ + httpx.Response(202, json={"run_id": "abc"}), + httpx.Response(404, text="pending"), + httpx.Response(404, text="pending"), + httpx.Response(200, json={"status": "completed"}), + ] + ) runner = AsyncHttpRunner(client=client, sleep=sleeps.append) - inv = _invocation(metadata_extra={ - "http.poll_pending_codes": "404", - "http.poll_interval_seconds": "0.5", - }) + inv = _invocation( + metadata_extra={ + "http.poll_pending_codes": "404", + "http.poll_interval_seconds": "0.5", + } + ) result = runner.run(inv) assert result.status == "succeeded" # 2 pending 404s + 0 non-terminal 200s before terminal → 2 sleeps. diff --git a/tests/runners/test_http_runner.py b/tests/runners/test_http_runner.py index 41c44e3..4e1c69c 100644 --- a/tests/runners/test_http_runner.py +++ b/tests/runners/test_http_runner.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Tests for HttpRunner — synchronous HTTP request/response runner.""" + import json import httpx @@ -29,8 +30,9 @@ def _invocation(**overrides) -> RuntimeInvocation: return RuntimeInvocation(**defaults) -def _stub_client(*, response: httpx.Response | None = None, - raises: Exception | None = None) -> httpx.Client: +def _stub_client( + *, response: httpx.Response | None = None, raises: Exception | None = None +) -> httpx.Client: """Build an httpx.Client that returns / raises the configured outcome.""" def _handler(request: httpx.Request) -> httpx.Response: @@ -76,10 +78,12 @@ def _handler(request): return httpx.Response(200) client = httpx.Client(transport=httpx.MockTransport(_handler)) - inv = _invocation(metadata={ - "http.url": "http://example.test/api/health", - "http.method": "GET", - }) + inv = _invocation( + metadata={ + "http.url": "http://example.test/api/health", + "http.method": "GET", + } + ) HttpRunner(client=client).run(inv) assert seen == ["GET"] @@ -91,10 +95,12 @@ def _handler(request): return httpx.Response(200) client = httpx.Client(transport=httpx.MockTransport(_handler)) - inv = _invocation(metadata={ - "http.url": "http://example.test/run", - "http.body": json.dumps({"task": "hello"}), - }) + inv = _invocation( + metadata={ + "http.url": "http://example.test/run", + "http.body": json.dumps({"task": "hello"}), + } + ) HttpRunner(client=client).run(inv) assert json.loads(seen[0]) == {"task": "hello"} @@ -117,10 +123,17 @@ def test_missing_url_returns_rejected(self): client = _stub_client() # Build an invocation directly without the default http.url metadata. inv = RuntimeInvocation( - invocation_id="inv-no-url", runtime_name="x", runtime_kind="http", - working_directory="/tmp", command=["x"], environment={}, - timeout_seconds=10, input_payload_path=None, output_result_path=None, - artifact_directory=None, metadata={}, + invocation_id="inv-no-url", + runtime_name="x", + runtime_kind="http", + working_directory="/tmp", + command=["x"], + environment={}, + timeout_seconds=10, + input_payload_path=None, + output_result_path=None, + artifact_directory=None, + metadata={}, ) result = HttpRunner(client=client).run(inv) assert result.status == "rejected" @@ -128,21 +141,25 @@ def test_missing_url_returns_rejected(self): def test_invalid_body_json_returns_rejected(self): client = _stub_client() - inv = _invocation(metadata={ - "http.url": "http://example.test/run", - "http.body": "{not-json", - }) + inv = _invocation( + metadata={ + "http.url": "http://example.test/run", + "http.body": "{not-json", + } + ) result = HttpRunner(client=client).run(inv) assert result.status == "rejected" assert "invalid body" in (result.error_summary or "") def test_unknown_body_format_returns_rejected(self): client = _stub_client() - inv = _invocation(metadata={ - "http.url": "http://example.test/run", - "http.body": "{}", - "http.body_format": "telepathy", - }) + inv = _invocation( + metadata={ + "http.url": "http://example.test/run", + "http.body": "{}", + "http.body_format": "telepathy", + } + ) result = HttpRunner(client=client).run(inv) assert result.status == "rejected" @@ -163,6 +180,7 @@ class TestImportGuard: def test_construction_without_httpx_raises_when_no_client(self, monkeypatch): """If httpx is missing and no client is injected, the constructor errors.""" import core_runner.runners.http_runner as mod + monkeypatch.setattr(mod, "httpx", None) with pytest.raises(ImportError, match="core-runner\\[http\\]"): HttpRunner() diff --git a/tests/runners/test_subprocess_runner.py b/tests/runners/test_subprocess_runner.py index 4d42f5c..8d18499 100644 --- a/tests/runners/test_subprocess_runner.py +++ b/tests/runners/test_subprocess_runner.py @@ -47,7 +47,8 @@ def test_timeout_command_returns_timed_out(tmp_path: Path) -> None: def test_missing_working_directory_returns_rejected(tmp_path: Path) -> None: missing = tmp_path / "missing" inv = _invocation( - tmp_path, [sys.executable, "-c", "print('x')"], + tmp_path, + [sys.executable, "-c", "print('x')"], invocation_id="inv-missing", working_directory=str(missing), timeout_seconds=5, @@ -91,7 +92,9 @@ def test_environment_overlay_visible_to_child_process(tmp_path: Path) -> None: def test_artifacts_returned_as_artifact_descriptors(tmp_path: Path) -> None: inv = _invocation( - tmp_path, [sys.executable, "-c", "print('hi')"], timeout_seconds=5, + tmp_path, + [sys.executable, "-c", "print('hi')"], + timeout_seconds=5, ) result = SubprocessRunner().run(inv) assert len(result.artifacts) == 2 diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 66b1ced..6ca7ffb 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Tests for dispatch-by-runtime_kind in CoreRunner.""" + from datetime import UTC, datetime import pytest diff --git a/tests/test_process.py b/tests/test_process.py index af6b349..1db1e0d 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -1,12 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 ProtocolWarden -import os -import signal import sys import textwrap -import pytest - from core_runner.process import SafeRunResult, safe_run