| from __future__ import annotations |
|
|
| import json |
| from dataclasses import dataclass |
| from typing import Any |
|
|
| from huggingface_hub import HfFileSystem, bucket_info, create_bucket |
|
|
| from .config import bucket_uri_from_source, normalize_bucket_name, settings, user_bucket_source |
| from .security import redact |
|
|
|
|
| @dataclass(frozen=True) |
| class RunPaths: |
| run_id: str |
| bucket_source: str |
|
|
| @property |
| def bucket_uri(self) -> str: |
| return bucket_uri_from_source(self.bucket_source) |
|
|
| @property |
| def root(self) -> str: |
| return f"{self.bucket_uri}/runs/{self.run_id}" |
|
|
| @property |
| def state(self) -> str: |
| return f"{self.root}/state.json" |
|
|
| @property |
| def events(self) -> str: |
| return f"{self.root}/events.jsonl" |
|
|
| @property |
| def report(self) -> str: |
| return f"{self.root}/report.md" |
|
|
|
|
| def _fs(token: str | None = None) -> HfFileSystem: |
| return HfFileSystem(token=token) |
|
|
|
|
| def check_user_bucket(*, username: str, bucket_name: str | None = None, token: str | None = None) -> dict[str, Any]: |
| """Return bucket readiness for the signed-in user without creating resources.""" |
| bucket_source = user_bucket_source(username=username, bucket_name=bucket_name) |
| bucket_uri = bucket_uri_from_source(bucket_source) |
| try: |
| info = bucket_info(bucket_source, token=token) |
| return { |
| "ok": True, |
| "exists": True, |
| "bucket_source": bucket_source, |
| "bucket_uri": bucket_uri, |
| "name": getattr(info, "name", normalize_bucket_name(bucket_name)), |
| "private": getattr(info, "private", None), |
| } |
| except Exception as exc: |
| error = str(exc) |
| not_found = any(marker in error.lower() for marker in ["404", "not found", "repository not found", "bucket not found"]) |
| return { |
| "ok": False, |
| "exists": False if not_found else None, |
| "bucket_source": bucket_source, |
| "bucket_uri": bucket_uri, |
| "error": error, |
| } |
|
|
|
|
| def create_user_bucket(*, username: str, bucket_name: str | None = None, token: str | None = None) -> dict[str, Any]: |
| """Create the signed-in user's private run bucket, then return readiness.""" |
| bucket_source = user_bucket_source(username=username, bucket_name=bucket_name) |
| try: |
| url = create_bucket(bucket_source, private=True, exist_ok=True, token=token) |
| except Exception as exc: |
| return { |
| "ok": False, |
| "exists": None, |
| "bucket_source": bucket_source, |
| "bucket_uri": bucket_uri_from_source(bucket_source), |
| "error": str(exc), |
| } |
| status = check_user_bucket(username=username, bucket_name=bucket_name, token=token) |
| status["created_or_existing"] = True |
| status["create_url"] = str(url) |
| return status |
|
|
|
|
| def assert_user_bucket_ready(*, username: str, bucket_name: str | None = None, token: str | None = None) -> dict[str, Any]: |
| """Raise a clear error if the user's run bucket cannot be used.""" |
| status = check_user_bucket(username=username, bucket_name=bucket_name, token=token) |
| if not status.get("ok"): |
| source = status.get("bucket_source") or user_bucket_source(username=username, bucket_name=bucket_name) |
| error = status.get("error") or "Bucket does not exist or is not accessible." |
| raise ValueError( |
| f"Run bucket `{source}` is not ready. Click 'Create private run bucket' first, " |
| f"or create it manually in Hugging Face Storage Buckets. Details: {error}" |
| ) |
| return status |
|
|
|
|
| def read_text(path: str, token: str | None = None) -> str | None: |
| fs = _fs(token) |
| try: |
| with fs.open(path, "r") as f: |
| return f.read() |
| except FileNotFoundError: |
| return None |
| except Exception as exc: |
| return f"[Could not read {path}: {exc}]" |
|
|
|
|
| def read_json(path: str, token: str | None = None) -> dict[str, Any] | None: |
| content = read_text(path, token=token) |
| if not content or content.startswith("[Could not read"): |
| return None |
| try: |
| return json.loads(content) |
| except json.JSONDecodeError: |
| return {"_error": "Invalid JSON", "raw": redact(content)} |
|
|
|
|
|
|
|
|
| def write_json(path: str, payload: dict[str, Any], token: str | None = None) -> None: |
| """Write a JSON document to a bucket path using HfFileSystem. |
| |
| Used by API routes to persist launch metadata immediately after a Job is |
| created, before the worker has had a chance to write state.json. |
| """ |
| fs = _fs(token) |
| with fs.open(path, "w") as f: |
| f.write(json.dumps(payload, indent=2, ensure_ascii=False)) |
|
|
|
|
| def write_launch_metadata(run_id: str, *, bucket_source: str, payload: dict[str, Any], token: str | None = None) -> None: |
| """Persist enough metadata immediately for the Run Explorer. |
| |
| Workers can take time before writing state.json/events.jsonl. The custom UI |
| should still be able to show a just-launched run, open its Job, and resume |
| polling after a page refresh. Store launch.json plus a lightweight |
| summary.json and minimal state.json fallback. The worker may overwrite |
| state.json later with richer information. |
| """ |
| paths = RunPaths(run_id, bucket_source=bucket_source) |
| launch = dict(payload) |
| launch.setdefault("run_id", run_id) |
| launch.setdefault("bucket_source", bucket_source) |
| launch.setdefault("status", "running") |
| write_json(f"{paths.root}/launch.json", launch, token=token) |
| summary = { |
| "run_id": run_id, |
| "bucket_source": bucket_source, |
| "kind": launch.get("kind") or "unknown", |
| "status": launch.get("status") or "running", |
| "model_id": launch.get("model_id") or "", |
| "target_space": launch.get("target_space") or "", |
| "target_space_url": launch.get("target_space_url") or (f"https://huggingface.co/spaces/{launch.get('target_space')}" if launch.get("target_space") else ""), |
| "job_id": launch.get("job_id") or "", |
| "job_url": launch.get("job_url") or "", |
| "created_by": launch.get("created_by") or launch.get("username") or "", |
| "created_at": launch.get("created_at") or "", |
| "updated_at": launch.get("updated_at") or launch.get("created_at") or "", |
| "selected_hardware": launch.get("preferred_space_hardware") or launch.get("selected_hardware") or "", |
| "artifacts_url": f"https://huggingface.co/buckets/{bucket_source}/tree/runs/{run_id}", |
| } |
| write_json(f"{paths.root}/summary.json", summary, token=token) |
| |
| |
| write_json( |
| f"{paths.root}/state.json", |
| { |
| "run_id": run_id, |
| "kind": summary["kind"], |
| "status": summary["status"], |
| "model_id": summary["model_id"], |
| "target_space": summary["target_space"], |
| "target_space_url": summary["target_space_url"], |
| "job_id": summary["job_id"], |
| "job_url": summary["job_url"], |
| "created_by": summary["created_by"], |
| "created_at": summary["created_at"], |
| "updated_at": summary["updated_at"], |
| }, |
| token=token, |
| ) |
|
|
|
|
| def read_events(run_id: str, *, bucket_source: str, token: str | None = None) -> list[dict[str, Any]]: |
| paths = RunPaths(run_id, bucket_source=bucket_source) |
| content = read_text(paths.events, token=token) |
| if not content: |
| return [] |
| events: list[dict[str, Any]] = [] |
| for line in content.splitlines(): |
| line = line.strip() |
| if not line: |
| continue |
| try: |
| events.append(json.loads(line)) |
| except json.JSONDecodeError: |
| events.append({"step": "parse_events", "status": "warning", "message": redact(line)}) |
| return events |
|
|
|
|
| def _safe_read_json(path: str, token: str | None = None) -> dict[str, Any]: |
| return read_json(path, token=token) or {} |
|
|
|
|
| def _safe_read_text(path: str, token: str | None = None) -> str: |
| return redact(read_text(path, token=token) or "") |
|
|
|
|
|
|
|
|
| def _owner_from_bucket_source(bucket_source: str | None) -> str: |
| if not bucket_source: |
| return "" |
| return str(bucket_source).split("/", 1)[0].strip() |
|
|
|
|
| def _job_url_from_sources(*, bucket_source: str, summary_file: dict[str, Any], launch: dict[str, Any], state: dict[str, Any]) -> str: |
| """Return a stable HF Job URL from any metadata shape we have. |
| |
| Older run bundles may have job_id in summary.json, created_by in launch.json, |
| or only the bucket owner available. The UI should still expose the Job link |
| whenever a job_id is known. |
| """ |
| explicit = summary_file.get("job_url") or launch.get("job_url") or state.get("job_url") |
| if explicit: |
| return str(explicit) |
| job_id = summary_file.get("job_id") or launch.get("job_id") or state.get("job_id") |
| owner = ( |
| summary_file.get("created_by") |
| or summary_file.get("username") |
| or summary_file.get("owner") |
| or launch.get("created_by") |
| or launch.get("username") |
| or launch.get("owner") |
| or state.get("created_by") |
| or state.get("username") |
| or state.get("owner") |
| or _owner_from_bucket_source(bucket_source) |
| ) |
| if job_id and owner: |
| return f"https://huggingface.co/jobs/{owner}/{job_id}" |
| return "" |
|
|
|
|
| def _job_url_from_launch_or_state(*, launch: dict[str, Any], state: dict[str, Any]) -> str: |
| |
| return _job_url_from_sources(bucket_source="", summary_file={}, launch=launch, state=state) |
|
|
|
|
| def _job_id_from_launch_or_state(*, launch: dict[str, Any], state: dict[str, Any]) -> str: |
| return str(launch.get("job_id") or state.get("job_id") or "") |
|
|
| def _list_run_files( |
| run_id: str, |
| *, |
| bucket_source: str, |
| token: str | None = None, |
| prefixes: tuple[str, ...] = ("generated", "tests", "logs", "artifacts", "traces", "repair"), |
| max_files: int = 120, |
| ) -> list[dict[str, Any]]: |
| """Return a compact, best-effort file index for a run. |
| |
| The Run Explorer should not fail when a Bucket contains partial or old runs. |
| This function therefore treats every listing error as non-fatal and limits |
| recursion so the UI stays responsive even when traces are large. |
| """ |
| fs = _fs(token) |
| base = f"{bucket_uri_from_source(bucket_source)}/runs/{run_id}" |
| files: list[dict[str, Any]] = [] |
|
|
| def walk(prefix: str, depth: int = 0) -> None: |
| if len(files) >= max_files or depth > 3: |
| return |
| path = f"{base}/{prefix}" |
| try: |
| entries = fs.ls(path, detail=True) |
| except Exception: |
| return |
| for entry in entries: |
| if len(files) >= max_files: |
| break |
| name = entry.get("name") if isinstance(entry, dict) else str(entry) |
| if not name: |
| continue |
| typ = str(entry.get("type") or "") if isinstance(entry, dict) else "" |
| size = entry.get("size") if isinstance(entry, dict) else None |
| rel = name.replace(base + "/", "", 1) |
| if typ == "directory": |
| walk(rel, depth + 1) |
| continue |
| files.append( |
| { |
| "path": rel, |
| "name": rel.split("/")[-1], |
| "size": size, |
| "url": f"https://huggingface.co/buckets/{bucket_source}/blob/runs/{run_id}/{rel}", |
| } |
| ) |
|
|
| for prefix in prefixes: |
| walk(prefix) |
| return files |
|
|
|
|
| def summarize_run_bundle(run_id: str, bundle: dict[str, Any], *, bucket_source: str) -> dict[str, Any]: |
| summary_file = bundle.get("summary_file") or {} |
| state = bundle.get("state") or {} |
| launch = bundle.get("launch") or {} |
| gate = bundle.get("inference_gate") or {} |
| smoke = bundle.get("generation_smoke") or {} |
| hardware = bundle.get("hardware_strategy") or {} |
| target_space = state.get("target_space") or launch.get("target_space") or summary_file.get("target_space") or "" |
| status = state.get("status") or launch.get("status") or summary_file.get("status") or gate.get("status") or smoke.get("status") or "unknown" |
| return { |
| "run_id": run_id, |
| "kind": state.get("kind") or launch.get("kind") or summary_file.get("kind") or "unknown", |
| "status": status, |
| "model_id": state.get("model_id") or launch.get("model_id") or summary_file.get("model_id") or state.get("model") or "", |
| "target_space": target_space, |
| "target_space_url": state.get("target_space_url") or launch.get("target_space_url") or summary_file.get("target_space_url") or (f"https://huggingface.co/spaces/{target_space}" if target_space else ""), |
| "job_id": state.get("job_id") or launch.get("job_id") or summary_file.get("job_id") or "", |
| "job_url": _job_url_from_sources(bucket_source=bucket_source, summary_file=summary_file, launch=launch, state=state), |
| "created_at": state.get("created_at") or launch.get("created_at") or summary_file.get("created_at") or "", |
| "updated_at": state.get("updated_at") or state.get("created_at") or launch.get("updated_at") or launch.get("created_at") or summary_file.get("updated_at") or summary_file.get("created_at") or "", |
| "selected_hardware": hardware.get("selected_hardware") or state.get("selected_hardware") or launch.get("preferred_space_hardware") or summary_file.get("selected_hardware") or state.get("hardware") or "", |
| "manual_hardware_required": bool(gate.get("manual_hardware_required") or hardware.get("manual_action_required") or launch.get("manual_hardware_required")), |
| "health_passed": bool((gate.get("implementation_signals") or {}).get("health_passed") or smoke.get("health_passed")), |
| "smoke_test_passed": bool(smoke.get("ok") or smoke.get("status") == "success"), |
| "latency_seconds": smoke.get("latency_seconds"), |
| "expected_output_type": smoke.get("expected_output_type") or state.get("expected_output_type") or launch.get("expected_output_type") or "", |
| "artifacts_url": f"https://huggingface.co/buckets/{bucket_source}/tree/runs/{run_id}", |
| } |
|
|
|
|
| def read_run_bundle(run_id: str, *, bucket_source: str, token: str | None = None) -> dict[str, Any]: |
| paths = RunPaths(run_id, bucket_source=bucket_source) |
| bundle = { |
| "paths": { |
| "root": paths.root, |
| "state": paths.state, |
| "events": paths.events, |
| "report": paths.report, |
| }, |
| "summary_file": _safe_read_json(f"{paths.root}/summary.json", token=token), |
| "launch": _safe_read_json(f"{paths.root}/launch.json", token=token), |
| "state": read_json(paths.state, token=token) or {}, |
| "events": read_events(run_id, bucket_source=bucket_source, token=token), |
| "report": _safe_read_text(paths.report, token=token), |
| "inference_gate": _safe_read_json(f"{paths.root}/inference_gate.json", token=token), |
| "generation_smoke": _safe_read_json(f"{paths.root}/tests/generation_smoke.json", token=token) or _safe_read_json(f"{paths.root}/generation_smoke.json", token=token), |
| "hardware_strategy": _safe_read_json(f"{paths.root}/hardware_strategy.json", token=token), |
| "hardware_attempts": _safe_read_json(f"{paths.root}/hardware_attempts.json", token=token), |
| "technical_blockers": _safe_read_json(f"{paths.root}/TECHNICAL_BLOCKERS.json", token=token), |
| "model_analysis": _safe_read_json(f"{paths.root}/model_analysis.json", token=token), |
| "space_runtime": _safe_read_json(f"{paths.root}/space_runtime.json", token=token), |
| "files": _list_run_files(run_id, bucket_source=bucket_source, token=token), |
| } |
| bundle["summary"] = summarize_run_bundle(run_id, bundle, bucket_source=bucket_source) |
| return bundle |
|
|
|
|
| def _run_id_from_path(path: str) -> str: |
| return path.rstrip('/').split('/')[-1] |
|
|
|
|
| def _discover_run_ids(root: str, *, token: str | None = None, limit: int = 300) -> list[str]: |
| """Discover run ids in a bucket even when the run folder is partial. |
| |
| HfFileSystem may expose object-store prefixes slightly differently between |
| Buckets/runtime versions. Use ls plus a few targeted globs so running Jobs |
| with only launch.json/summary.json are still shown in the Run Explorer. |
| """ |
| fs = _fs(token) |
| run_ids: set[str] = set() |
|
|
| def add_from_path(path: str) -> None: |
| if "/runs/" in path: |
| tail = path.split("/runs/", 1)[1] |
| else: |
| tail = path.replace(root.rstrip("/") + "/", "", 1) |
| run_id = tail.strip("/").split("/", 1)[0] |
| if run_id and run_id != "runs": |
| run_ids.add(run_id) |
|
|
| try: |
| for entry in fs.ls(root, detail=True): |
| name = entry.get("name") if isinstance(entry, dict) else str(entry) |
| if name: |
| add_from_path(name) |
| except Exception: |
| pass |
|
|
| for pattern in (f"{root}/*", f"{root}/*/summary.json", f"{root}/*/launch.json", f"{root}/*/state.json"): |
| try: |
| for path in fs.glob(pattern): |
| add_from_path(str(path)) |
| if len(run_ids) >= limit: |
| break |
| except Exception: |
| continue |
|
|
| return sorted(run_ids, reverse=True)[:limit] |
|
|
|
|
| def list_recent_runs( |
| *, |
| bucket_source: str, |
| token: str | None = None, |
| limit: int = 50, |
| query: str | None = None, |
| status: str | None = None, |
| ) -> list[dict[str, Any]]: |
| """List recent run summaries from a user's bucket. |
| |
| Best effort: a run can be visible as soon as launch.json/summary.json exists, |
| before the worker writes full state/events. This keeps running Jobs visible |
| and makes Job links available immediately. |
| """ |
| root = f"{bucket_uri_from_source(bucket_source)}/runs" |
| run_ids = _discover_run_ids(root, token=token) |
| if not run_ids: |
| return [] |
|
|
| runs: list[dict[str, Any]] = [] |
| for run_id in run_ids: |
| summary_file = read_json(f"{root}/{run_id}/summary.json", token=token) or {} |
| launch = read_json(f"{root}/{run_id}/launch.json", token=token) or {} |
| state = read_json(f"{root}/{run_id}/state.json", token=token) or {} |
| gate = read_json(f"{root}/{run_id}/inference_gate.json", token=token) or {} |
| smoke = read_json(f"{root}/{run_id}/tests/generation_smoke.json", token=token) or read_json(f"{root}/{run_id}/generation_smoke.json", token=token) or {} |
| hardware = read_json(f"{root}/{run_id}/hardware_strategy.json", token=token) or {} |
| partial_bundle = { |
| "summary_file": summary_file, |
| "launch": launch, |
| "state": state, |
| "inference_gate": gate, |
| "generation_smoke": smoke, |
| "hardware_strategy": hardware, |
| } |
| item = summarize_run_bundle(run_id, partial_bundle, bucket_source=bucket_source) |
| item["bucket_source"] = bucket_source |
| haystack = " ".join(str(item.get(k, "")) for k in ["run_id", "model_id", "target_space", "status", "kind", "job_id"]).lower() |
| if query and query.lower() not in haystack: |
| continue |
| if status and status not in {"all", ""} and item["status"] != status: |
| continue |
| runs.append(item) |
|
|
| runs.sort(key=lambda r: str(r.get("updated_at") or r.get("created_at") or r.get("run_id")), reverse=True) |
| return runs[: max(1, min(int(limit or 50), 200))] |
|
|