from __future__ import annotations import json import logging import os import signal import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Any from supply_infra.config import InfraSettings, get_infra_settings from supply_infra.pipeline.contracts import StepResult logger = logging.getLogger(__name__) _REPO_ROOT = Path(__file__).resolve().parents[2] _MAX_RESULT_BYTES = 100_000 @dataclass(frozen=True) class StepExecutionRequest: run_id: str step_run_id: str step_key: str timeout_seconds: int def _log_directory(settings: InfraSettings, run_id: str) -> Path: base = Path(settings.pipeline_log_dir) if not base.is_absolute(): base = _REPO_ROOT / base path = base / run_id path.mkdir(parents=True, exist_ok=True) return path def _read_result(path: Path) -> dict[str, Any]: if not path.is_file(): return {"success": False, "error_code": "missing_result", "error": "Result file missing"} raw = path.read_bytes() if len(raw) > _MAX_RESULT_BYTES: return { "success": False, "error_code": "result_too_large", "error": f"Step result exceeds {_MAX_RESULT_BYTES} bytes", "result_bytes": len(raw), } try: payload = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: return { "success": False, "error_code": "invalid_result_json", "error": str(exc), } if not isinstance(payload, dict): return { "success": False, "error_code": "invalid_result_type", "error": "Step result must be a JSON object", } return payload def run_step_subprocess( request: StepExecutionRequest, *, settings: InfraSettings | None = None, ) -> StepResult: active = settings or get_infra_settings() directory = _log_directory(active, request.run_id) stem = f"{request.step_key}-attempt-{request.step_run_id}" log_path = directory / f"{stem}.log" result_path = directory / f"{stem}.result.json" result_path.unlink(missing_ok=True) command = [ sys.executable, "-m", "supply_infra.pipeline.step_cli", request.step_run_id, "--result-file", str(result_path), ] env = os.environ.copy() env["PROCESS_ROLE"] = "step" logger.info("Starting pipeline step: step_run_id=%s", request.step_run_id) timed_out = False exit_code: int | None = None with log_path.open("a", encoding="utf-8") as log_file: try: process = subprocess.Popen( command, cwd=str(_REPO_ROOT), env=env, stdout=log_file, stderr=subprocess.STDOUT, text=True, start_new_session=True, ) except Exception as exc: logger.exception("Failed to launch pipeline step: %s", request.step_run_id) return StepResult( success=False, payload={}, error_code="step_launch_failed", error_message=str(exc), log_uri=str(log_path), ) try: exit_code = process.wait(timeout=request.timeout_seconds) except subprocess.TimeoutExpired: timed_out = True os.killpg(process.pid, signal.SIGTERM) try: exit_code = process.wait(timeout=min(active.pipeline_shutdown_grace_seconds, 30)) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) exit_code = process.wait() if timed_out: return StepResult( success=False, payload={}, error_code="step_timed_out", error_message=f"Step timed out after {request.timeout_seconds}s", exit_code=exit_code, log_uri=str(log_path), timed_out=True, ) payload = _read_result(result_path) result_path.unlink(missing_ok=True) success = exit_code == 0 and payload.get("success") is not False return StepResult( success=success, payload=payload, error_code=( None if success else str(payload.get("error_code") or "step_failed") ), error_message=( None if success else str(payload.get("error") or f"Step exited with code {exit_code}") ), exit_code=exit_code, log_uri=str(log_path), )