"""Fase 4d hosted entrypoint (``hosting.py`` + root ``main.py``): the Foundry hosted-agent runtime contract implemented DIRECTLY around ``run_project`` — the wrapper form BOTH 13.08 measurements prescribe (hosting-pakka: ``InvocationsHostServer`` finnes kun i bygg som krever core>=1.13.0; gjenbrukt workflow: kall-serie [2, 0, 0] — single-use på 1.9.0). Load-bearing surface pinned here: * ``GET /readiness`` → 200. We use NO protocol library (the measurement above), so the endpoint the platform health-checks is OURS to serve — nothing serves it for us. * ``POST /invocations`` wires the payload's whitelisted fields into ``run_project`` and returns the outbox-shaped outcome payload. ``profile`` defaults to ``"azure"`` on THIS surface (a hosted container has no local endpoint; run_project's own default stays LOCAL). * Validation, NEVER repair: an unknown field is a 400 naming the field — the permissive-schema trap (valg-doc §0: "en fil som ser konfigurert ut og ikke er det") applied to our own surface. * Honest error mapping: ``ValueError`` (incl. pydantic contract violations) → 400; run failures → 500 ``{error_type, error}`` (mirrors ``RunFailure``); a ``Rejection`` is a SUCCESSFUL run → 200 — the negative outcome belongs to the payload, never to the transport. * The server is asyncio on the ONE loop (NG1: ``test_no_thread_or_process_path_exists_under_src`` ratchets src/ thread-free) — these tests run client and server as coroutines on the SAME loop, which only works because nothing in the server blocks it. * Root ``main.py`` is the ONE process entry (Dockerfile CMD + azure.yaml point at it): the subprocess test is the ONLY test that catches a detached shim or a detached SIGTERM handler (P4-presedensen: entry-point-mutasjoner fanges aldri av in-process-tester). """ from __future__ import annotations import asyncio import json import os import signal import socket import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path from typing import Any import pytest from portfolio_optimiser import hosting from portfolio_optimiser.budget import BudgetExceeded from portfolio_optimiser.ir import AffectedItem, SavingsProposal from portfolio_optimiser.provenance import Citation, ProvenanceStamp from portfolio_optimiser.retrieval import TextSpan from portfolio_optimiser.run import RunResult from portfolio_optimiser.validator import Rejection, ValidatedProposal from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, VerdictStore _PROPOSAL = SavingsProposal( project_id="P1", measure="LED-retrofit av kontorbelysning", affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=1000.0, unit_cost=1.0)], claimed_saving_nok=200.0, assumptions={"ENERGI-TOTAL-EL": (0.8, 1.2)}, ) _PROVENANCE = ProvenanceStamp( citations=[ Citation(file="f.md", locator=TextSpan(start_index=0, end_index=5), snippet="hello") ], model="synthetic", role="proposer", validator_decision="validated", token_usage=8, ) _VALIDATED = ValidatedProposal( proposal=_PROPOSAL, p10=100.0, p50=150.0, p90=200.0, nominal_feasible=180.0 ) _REJECTION = Rejection(proposal=_PROPOSAL, reason="stage 0: unknown cost code") _VERDICT = Verdict( id="vid-hosted", proposal_features=ProposalFeatures( affected_codes=frozenset({"ENERGI-TOTAL-EL"}), measure_type="LED-retrofit av kontorbelysning", claimed_saving_nok=200.0, ), decision="approved", rationale="expert reviewed", ) _PAYLOAD = { "project_id": "P1", "docs_dir": "docs", "verdict_input": {"decision": "approved", "rationale": "expert"}, } def _result(outcome: ValidatedProposal | Rejection) -> RunResult: return RunResult( outcome=outcome, provenance=_PROVENANCE, verdict=_VERDICT, retrieved=[], store=VerdictStore(verdicts=[]), debate_output="debate", checker_verdict="approve", ) class _Recorder: """An async stand-in for ``run_project``: records every (args, kwargs), then returns the configured RunResult or raises the configured error.""" def __init__( self, result: RunResult | None = None, error: Exception | None = None, ) -> None: self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] self._run_result = result self._error = error async def __call__(self, *args: Any, **kwargs: Any) -> RunResult: self.calls.append((args, kwargs)) if self._error is not None: raise self._error assert self._run_result is not None return self._run_result @pytest.fixture() async def served() -> Any: """The contract server on THIS test's loop (port 0 → ephemeral). Client and server share the loop, so a server that blocked it would hang these tests — the fixture is itself a check that nothing in the request path blocks.""" server = await hosting.start_server("127.0.0.1", 0) port = server.sockets[0].getsockname()[1] yield f"127.0.0.1:{port}" server.close() await server.wait_closed() async def _request( base: str, method: str, path: str, body: bytes | None = None ) -> tuple[int, bytes]: """A minimal HTTP/1.1 client on the same loop (urllib would block the shared loop).""" host, port_text = base.split(":") reader, writer = await asyncio.open_connection(host, int(port_text)) head = f"{method} {path} HTTP/1.1\r\nHost: {base}\r\nConnection: close\r\n" if body is not None: head += f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n" writer.write(head.encode("latin-1") + b"\r\n" + (body or b"")) await writer.drain() raw = await reader.read() writer.close() await writer.wait_closed() status = int(raw.split(b" ", 2)[1]) return status, raw.split(b"\r\n\r\n", 1)[1] async def _get(base: str, path: str) -> tuple[int, bytes]: return await _request(base, "GET", path) async def _post(base: str, path: str, payload: Any) -> tuple[int, dict[str, Any]]: body = payload if isinstance(payload, bytes) else json.dumps(payload).encode("utf-8") status, raw = await _request(base, "POST", path, body) return status, json.loads(raw.decode("utf-8")) async def test_readiness_returns_200(served: str) -> None: """The platform health-checks GET /readiness; with no protocol library, serving it is ours.""" status, _body = await _get(served, "/readiness") assert status == 200 async def test_unknown_paths_are_404(served: str) -> None: status, _ = await _get(served, "/other") assert status == 404 status, _ = await _post(served, "/other", _PAYLOAD) assert status == 404 async def test_invocations_wires_payload_into_run_project( served: str, monkeypatch: pytest.MonkeyPatch ) -> None: """The wiring seam: the payload's fields reach run_project as its own arguments, the hosted default profile is 'azure', and the response carries the outbox-shaped outcome + proposal + provenance. RED if the handler detaches the runner or drops the field mapping.""" recorder = _Recorder(result=_result(_VALIDATED)) monkeypatch.setattr(hosting, "run_project", recorder) status, body = await _post(served, "/invocations", {**_PAYLOAD, "max_rounds": 2}) assert status == 200 assert recorder.calls, "run_project was never called" args, kwargs = recorder.calls[0] assert args == ("P1",) assert kwargs["docs_dir"] == "docs" assert kwargs["verdict_input"] == {"decision": "approved", "rationale": "expert"} assert kwargs["max_rounds"] == 2 # Hosted default: a container has no local OpenAI-compatible endpoint (run_project's own # default stays LOCAL — the two defaults are different on purpose, and this pins OURS). assert kwargs["profile"] == "azure" assert body["outcome_type"] == "validated" assert body["p90"] == 200.0 assert body["checker_verdict"] == "approve" assert body["verdict_id"] == "vid-hosted" assert body["proposal"]["measure"] == "LED-retrofit av kontorbelysning" assert body["provenance"]["model"] == "synthetic" async def test_profile_in_payload_overrides_hosted_default( served: str, monkeypatch: pytest.MonkeyPatch ) -> None: recorder = _Recorder(result=_result(_VALIDATED)) monkeypatch.setattr(hosting, "run_project", recorder) status, _body = await _post(served, "/invocations", {**_PAYLOAD, "profile": "local"}) assert status == 200 _args, kwargs = recorder.calls[0] assert kwargs["profile"] == "local" async def test_unknown_field_is_refused_never_repaired( served: str, monkeypatch: pytest.MonkeyPatch ) -> None: """Validation, never repair: an unknown field is a 400 naming the field and the runner is never reached — dropping it silently would be the permissive-schema failure mode (valg §0). The control arm (same payload minus the key) proves the refusal is FOR the key.""" recorder = _Recorder(result=_result(_VALIDATED)) monkeypatch.setattr(hosting, "run_project", recorder) status, body = await _post(served, "/invocations", {**_PAYLOAD, "outbox_dir": "/x"}) assert status == 400 assert "outbox_dir" in body["error"] assert recorder.calls == [] # refused BEFORE the runner — never repaired-and-run control_status, _ = await _post(served, "/invocations", _PAYLOAD) assert control_status == 200 assert len(recorder.calls) == 1 @pytest.mark.parametrize("missing", ["project_id", "docs_dir", "verdict_input"]) async def test_missing_required_field_is_400( served: str, monkeypatch: pytest.MonkeyPatch, missing: str ) -> None: recorder = _Recorder(result=_result(_VALIDATED)) monkeypatch.setattr(hosting, "run_project", recorder) payload = {k: v for k, v in _PAYLOAD.items() if k != missing} status, body = await _post(served, "/invocations", payload) assert status == 400 assert missing in body["error"] assert recorder.calls == [] async def test_non_object_or_invalid_json_body_is_400( served: str, monkeypatch: pytest.MonkeyPatch ) -> None: recorder = _Recorder(result=_result(_VALIDATED)) monkeypatch.setattr(hosting, "run_project", recorder) status, _ = await _post(served, "/invocations", b"[1, 2]") assert status == 400 status, _ = await _post(served, "/invocations", b"not json") assert status == 400 assert recorder.calls == [] async def test_rejected_outcome_is_200_with_reason( served: str, monkeypatch: pytest.MonkeyPatch ) -> None: """A Rejection is a successful run with a negative outcome — 200, outcome_type=rejected, the reason, and NO percentile keys (key-absence on the parsed dict, not a substring).""" recorder = _Recorder(result=_result(_REJECTION)) monkeypatch.setattr(hosting, "run_project", recorder) status, body = await _post(served, "/invocations", _PAYLOAD) assert status == 200 assert body["outcome_type"] == "rejected" assert body["reason"] == "stage 0: unknown cost code" assert "p90" not in body async def test_contract_violation_is_400_and_run_failure_is_500( served: str, monkeypatch: pytest.MonkeyPatch ) -> None: """ValueError (pydantic contract violations subclass it) is the CALLER's error → 400; any other failure is an honest 500 carrying {error_type, error} (mirrors RunFailure's shape). BudgetExceeded is RuntimeError, so it lands in the 500 arm — with observed != limit so the two can never be conflated by an echo (kø-(y)).""" monkeypatch.setattr( hosting, "run_project", _Recorder(error=ValueError("docs_dir does not exist")) ) status, body = await _post(served, "/invocations", _PAYLOAD) assert status == 400 assert "docs_dir does not exist" in body["error"] monkeypatch.setattr(hosting, "run_project", _Recorder(error=BudgetExceeded("tokens", 100, 173))) status, body = await _post(served, "/invocations", _PAYLOAD) assert status == 500 assert body["error_type"] == "BudgetExceeded" assert "limit=100" in body["error"] assert "173" in body["error"] async def test_readiness_answers_while_an_invocation_is_in_flight( served: str, monkeypatch: pytest.MonkeyPatch ) -> None: """Liveness on ONE loop: while an invocation awaits (model I/O), /readiness must still answer — the platform health-checks during long runs, and a server that serialized the whole process on one in-flight request would be killed as unready.""" release = asyncio.Event() class _Blocking(_Recorder): async def __call__(self, *args: Any, **kwargs: Any) -> RunResult: await release.wait() return await super().__call__(*args, **kwargs) monkeypatch.setattr(hosting, "run_project", _Blocking(result=_result(_VALIDATED))) in_flight = asyncio.ensure_future(_post(served, "/invocations", _PAYLOAD)) try: status, _ = await asyncio.wait_for(_get(served, "/readiness"), timeout=10) assert status == 200 finally: release.set() status, _body = await asyncio.wait_for(in_flight, timeout=10) assert status == 200 def test_port_resolution_is_truthiness_not_presence(monkeypatch: pytest.MonkeyPatch) -> None: """PORT on truthiness (the 4b rule): an exported-empty PORT is a shell accident, not a bind instruction. Default 8088 is the hosted-agent contract's port.""" monkeypatch.delenv("PORT", raising=False) assert hosting.resolve_port() == 8088 monkeypatch.setenv("PORT", "9001") assert hosting.resolve_port() == 9001 monkeypatch.setenv("PORT", "") assert hosting.resolve_port() == 8088 def _blocking_get(url: str) -> tuple[int, str]: try: with urllib.request.urlopen(url, timeout=10) as resp: return resp.status, resp.read().decode("utf-8") except urllib.error.HTTPError as err: return err.code, err.read().decode("utf-8") def test_main_entrypoint_serves_and_stops_on_sigterm() -> None: """Root main.py is the ONE process entry (Dockerfile CMD + azure.yaml point at it): started as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that catches a shim that stops calling hosting.main() or a detached SIGTERM handler.""" with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) port = probe.getsockname()[1] repo_root = Path(__file__).resolve().parents[1] proc = subprocess.Popen( [sys.executable, str(repo_root / "main.py")], env={**os.environ, "PORT": str(port)}, cwd=repo_root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) try: deadline = time.monotonic() + 60 up = False while time.monotonic() < deadline: try: status, _ = _blocking_get(f"http://127.0.0.1:{port}/readiness") if status == 200: up = True break except (urllib.error.URLError, OSError): time.sleep(0.2) assert up, "main.py never served /readiness" proc.send_signal(signal.SIGTERM) assert proc.wait(timeout=15) == 0 finally: if proc.poll() is None: proc.kill() proc.wait()