generated from coulomb/repo-seed
Expose retained runs through service API
This commit is contained in:
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user