Expose retained runs through service API

This commit is contained in:
2026-05-16 03:04:17 +02:00
parent 2412f30975
commit 2a1a53c140
8 changed files with 378 additions and 21 deletions

View File

@@ -10,7 +10,7 @@ from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.parse import parse_qs, unquote, urlparse
from guide_board.discovery import discover_extensions
from guide_board.errors import GuideBoardError
@@ -21,6 +21,11 @@ from guide_board.planning import (
validate_assessment_profile,
validate_target_profile,
)
from guide_board.retention import (
list_retained_runs,
retained_run_report_paths,
select_retained_run,
)
@dataclass(frozen=True)
@@ -131,7 +136,7 @@ class GuideBoardRequestHandler(BaseHTTPRequestHandler):
def _handle(self, method: str) -> None:
parsed = urlparse(self.path)
try:
response, status_code = self._route(method, parsed.path)
response, status_code = self._route(method, parsed.path, parsed.query)
except HttpProblem as exc:
response = _error_response(exc.message, exc.__class__.__name__, exc.status_code)
status_code = exc.status_code
@@ -147,7 +152,8 @@ class GuideBoardRequestHandler(BaseHTTPRequestHandler):
self._send_json(status_code, response)
def _route(self, method: str, path: str) -> tuple[dict[str, Any], int]:
def _route(self, method: str, path: str, query: str = "") -> tuple[dict[str, Any], int]:
query_params = _query_params(query)
if method == "GET" and path == "/health":
return self._health(), 200
if method == "GET" and path == "/extensions":
@@ -160,6 +166,10 @@ class GuideBoardRequestHandler(BaseHTTPRequestHandler):
return {"runs": self.server.context.jobs.list()}, 200
if method == "POST" and path == "/runs":
return self._start_run(), 202
if method == "GET" and path == "/retained-runs":
return self._retained_runs(query_params), 200
if method == "GET" and path == "/retained-runs/latest":
return self._retained_latest(query_params), 200
run_match = _match_run_path(path)
if method == "GET" and run_match is not None:
@@ -169,6 +179,14 @@ class GuideBoardRequestHandler(BaseHTTPRequestHandler):
if suffix == "reports":
return self._run_reports(job_id), 200
retained_match = _match_retained_run_path(path)
if method == "GET" and retained_match is not None:
run_id, suffix = retained_match
if suffix == "reports":
return self._retained_run_reports(run_id, query_params), 200
if suffix == "artifact-manifest":
return self._retained_artifact_manifest(run_id, query_params), 200
raise HttpProblem(404, f"endpoint not found: {method} {path}")
def _health(self) -> dict[str, Any]:
@@ -261,10 +279,21 @@ class GuideBoardRequestHandler(BaseHTTPRequestHandler):
report_path = Path(result["report"])
package_path = Path(result["assessment_package"])
retention_path = Path(result["retention_summary"])
submission_value = result.get("submission_package")
submission_path = (
Path(submission_value)
if isinstance(submission_value, str) and submission_value
else None
)
try:
report_markdown = report_path.read_text(encoding="utf-8")
assessment_package = load_json(package_path)
retention_summary = load_json(retention_path)
submission_package = (
load_json(submission_path)
if submission_path is not None and submission_path.is_file()
else None
)
except OSError as exc:
raise HttpProblem(404, f"run report artifact is missing: {exc}") from exc
@@ -277,6 +306,7 @@ class GuideBoardRequestHandler(BaseHTTPRequestHandler):
"report": str(report_path),
"assessment_package": str(package_path),
"retention_summary": str(retention_path),
"submission_package": str(submission_path) if submission_path is not None else None,
},
"report": {
"path": str(report_path),
@@ -290,6 +320,69 @@ class GuideBoardRequestHandler(BaseHTTPRequestHandler):
"path": str(retention_path),
"json": retention_summary,
},
"submission_package": {
"path": str(submission_path) if submission_package else None,
"json": submission_package,
},
}
def _retained_runs(self, query: dict[str, str]) -> dict[str, Any]:
runs_dir = _runs_dir_from_query(self.server.context.root, query)
return {
"runs_dir": str(runs_dir),
"runs": list_retained_runs(runs_dir),
}
def _retained_latest(self, query: dict[str, str]) -> dict[str, Any]:
runs_dir = _runs_dir_from_query(self.server.context.root, query)
run = select_retained_run(
runs_dir,
target_profile_ref=query.get("target"),
assessment_profile_ref=query.get("assessment"),
)
return {
"runs_dir": str(runs_dir),
"selection": {
"target_profile_ref": query.get("target"),
"assessment_profile_ref": query.get("assessment"),
},
"run": _retained_run_with_paths(run) if run else None,
}
def _retained_run_reports(self, run_id: str, query: dict[str, str]) -> dict[str, Any]:
runs_dir = _runs_dir_from_query(self.server.context.root, query)
run = _select_retained_run_or_404(runs_dir, run_id)
return {
"runs_dir": str(runs_dir),
"run": _retained_run_with_paths(run),
}
def _retained_artifact_manifest(self, run_id: str, query: dict[str, str]) -> dict[str, Any]:
runs_dir = _runs_dir_from_query(self.server.context.root, query)
run = _select_retained_run_or_404(runs_dir, run_id)
run_dir = _safe_run_dir(runs_dir, run)
package_path = run_dir / "reports" / "assessment-package.json"
if not package_path.exists():
return {
"runs_dir": str(runs_dir),
"run_id": run_id,
"run_dir": str(run_dir),
"artifact_manifest": [],
"compatibility": "assessment-package-missing",
}
package = load_json(package_path)
artifacts = package.get("artifact_manifest", [])
if not isinstance(artifacts, list):
raise HttpProblem(400, f"{package_path}: artifact_manifest must be a list")
for artifact in artifacts:
if isinstance(artifact, dict):
_safe_run_ref(run_dir, artifact.get("path"))
return {
"runs_dir": str(runs_dir),
"run_id": run_id,
"run_dir": str(run_dir),
"artifact_manifest": artifacts,
"compatibility": "current",
}
def _read_payload(self) -> dict[str, Any]:
@@ -430,6 +523,81 @@ def _match_run_path(path: str) -> tuple[str, str | None] | None:
return None
def _match_retained_run_path(path: str) -> tuple[str, str] | None:
parts = [unquote(part) for part in path.split("/") if part]
if len(parts) == 3 and parts[0] == "retained-runs":
return parts[1], parts[2]
return None
def _query_params(query: str) -> dict[str, str]:
parsed = parse_qs(query, keep_blank_values=False)
params = {}
for key, values in parsed.items():
if values:
params[key] = values[-1]
return params
def _runs_dir_from_query(root: Path, query: dict[str, str]) -> Path:
runs_dir = query.get("runs_dir")
if not runs_dir:
return (root / "runs").resolve()
return _resolve_path(root, runs_dir)
def _select_retained_run_or_404(runs_dir: Path, run_id: str) -> dict[str, Any]:
run = select_retained_run(runs_dir, run_id=run_id)
if run is None:
raise HttpProblem(404, f"retained run not found: {run_id}")
return run
def _retained_run_with_paths(run: dict[str, Any] | None) -> dict[str, Any] | None:
if run is None:
return None
paths = retained_run_report_paths(run)
run_dir = Path(run["run_dir"]).resolve()
safe_paths = {}
for key, value in paths.items():
path = Path(value).resolve()
try:
path.relative_to(run_dir)
except ValueError as exc:
raise HttpProblem(
400,
f"retained run report path escapes run directory: {value}",
) from exc
safe_paths[key] = str(path)
return {
**run,
"paths": dict(sorted(safe_paths.items())),
}
def _safe_run_dir(runs_dir: Path, run: dict[str, Any]) -> Path:
run_dir_value = run.get("run_dir")
if not isinstance(run_dir_value, str) or not run_dir_value:
raise HttpProblem(400, "retained run is missing run_dir")
run_dir = Path(run_dir_value).resolve()
try:
run_dir.relative_to(runs_dir.resolve())
except ValueError as exc:
raise HttpProblem(400, f"retained run escapes runs_dir: {run_dir}") from exc
return run_dir
def _safe_run_ref(run_dir: Path, ref: Any) -> Path:
if not isinstance(ref, str) or not ref:
raise HttpProblem(400, "artifact manifest entry path must be a non-empty string")
path = (run_dir / ref).resolve()
try:
path.relative_to(run_dir.resolve())
except ValueError as exc:
raise HttpProblem(400, f"artifact path escapes run directory: {ref}") from exc
return path
def _display_path(root: Path, path: Path) -> str:
try:
return str(path.resolve().relative_to(root.resolve()))