* feat(W-026): add read-only workbench API * fix(workbench): harden read-only API review gaps
91 lines
4.2 KiB
Python
91 lines
4.2 KiB
Python
"""Small stdlib route layer for CORE Workbench W-026."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
|
|
from workbench import readers
|
|
from workbench.readers import ArtifactTooLargeError
|
|
from workbench.schemas import error, ok
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ApiResponse:
|
|
status: int
|
|
payload: dict[str, Any]
|
|
|
|
|
|
class WorkbenchApi:
|
|
def handle(self, method: str, raw_path: str, body: bytes = b"") -> ApiResponse:
|
|
parsed = urlparse(raw_path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
query = parse_qs(parsed.query)
|
|
try:
|
|
return self._dispatch(method.upper(), path, query, body)
|
|
except json.JSONDecodeError as exc:
|
|
return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc)))
|
|
except ValueError as exc:
|
|
return ApiResponse(400, error("bad_request", str(exc)))
|
|
except FileNotFoundError as exc:
|
|
missing = str(exc) or "resource"
|
|
return ApiResponse(404, error("not_found", f"not found: {missing}"))
|
|
except ArtifactTooLargeError as exc:
|
|
return ApiResponse(413, error("read_error", str(exc)))
|
|
except OSError as exc:
|
|
return ApiResponse(500, error("read_error", str(exc)))
|
|
except Exception as exc: # noqa: BLE001 - API contract requires JSON errors.
|
|
return ApiResponse(500, error("runtime_unavailable", f"internal error: {exc}"))
|
|
|
|
def _dispatch(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
query: dict[str, list[str]],
|
|
body: bytes,
|
|
) -> ApiResponse:
|
|
if method == "GET" and path == "/health":
|
|
return ApiResponse(200, ok({"status": "ok"}))
|
|
if method == "GET" and path == "/runtime/status":
|
|
return ApiResponse(200, ok(readers.runtime_status()))
|
|
if method == "GET" and path == "/artifacts":
|
|
limit = int(query.get("limit", ["100"])[0])
|
|
return ApiResponse(200, ok({"items": readers.list_artifacts(limit=limit)}))
|
|
if method == "GET" and path.startswith("/artifacts/"):
|
|
artifact_id = unquote(path.removeprefix("/artifacts/"))
|
|
return ApiResponse(200, ok(readers.read_artifact(artifact_id)))
|
|
if method == "GET" and path == "/proposals":
|
|
return ApiResponse(200, ok({"items": readers.list_proposals()}))
|
|
if method == "GET" and path.startswith("/proposals/"):
|
|
proposal_id = unquote(path.removeprefix("/proposals/"))
|
|
return ApiResponse(200, ok(readers.read_proposal(proposal_id)))
|
|
if method == "GET" and path == "/evals":
|
|
return ApiResponse(200, ok({"lanes": readers.list_eval_lanes()}))
|
|
if method == "GET" and path.startswith("/evals/"):
|
|
lane = unquote(path.removeprefix("/evals/"))
|
|
return ApiResponse(200, ok(readers.read_eval_lane(lane)))
|
|
if method == "POST" and path == "/evals/run":
|
|
request = json.loads(body.decode("utf-8") or "{}")
|
|
if not isinstance(request, dict):
|
|
return ApiResponse(400, error("bad_request", "eval request must be an object"))
|
|
try:
|
|
result = readers.run_safe_eval_lane(
|
|
str(request.get("lane") or ""),
|
|
version=str(request.get("version") or "v1"),
|
|
split=str(request.get("split") or "public"),
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return ApiResponse(404, error("not_found", str(exc)))
|
|
except ValueError as exc:
|
|
return ApiResponse(400, error("bad_request", str(exc)))
|
|
return ApiResponse(200, ok(result))
|
|
if method == "GET" and path.startswith("/trace/"):
|
|
return ApiResponse(404, error("not_found", "trace storage is not wired in W-026"))
|
|
if (
|
|
(method == "POST" and path == "/chat/turn")
|
|
or (method == "GET" and path.startswith("/replay/"))
|
|
):
|
|
return ApiResponse(501, error("unsupported", "route is deferred beyond W-026"))
|
|
return ApiResponse(404, error("not_found", f"route not found: {method} {path}"))
|