Upload 8 files
Browse files- src/auth.py +199 -0
- src/bucket.py +300 -0
- src/config.py +40 -0
- src/jobs.py +218 -0
- src/progress.py +197 -0
- src/runs.py +23 -0
- src/security.py +24 -0
- src/worker_payload.py +1428 -0
src/auth.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import HTTPException, Request
|
| 8 |
+
from huggingface_hub import HfApi
|
| 9 |
+
|
| 10 |
+
try: # huggingface_hub>=1.0 provides official FastAPI helpers.
|
| 11 |
+
from huggingface_hub import parse_huggingface_oauth
|
| 12 |
+
except Exception: # pragma: no cover - compatibility fallback for older local envs.
|
| 13 |
+
parse_huggingface_oauth = None # type: ignore[assignment]
|
| 14 |
+
|
| 15 |
+
from .security import redact
|
| 16 |
+
|
| 17 |
+
REQUIRED_OAUTH_SCOPES: set[str] = {
|
| 18 |
+
"read-repos",
|
| 19 |
+
"write-repos",
|
| 20 |
+
"manage-repos",
|
| 21 |
+
"gated-repos",
|
| 22 |
+
"inference-api",
|
| 23 |
+
"jobs",
|
| 24 |
+
"read-billing",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class OAuthContext:
|
| 30 |
+
username: str
|
| 31 |
+
token: str
|
| 32 |
+
profile: dict[str, Any] = field(default_factory=dict)
|
| 33 |
+
scopes: set[str] = field(default_factory=set)
|
| 34 |
+
expires_at: datetime | None = None
|
| 35 |
+
is_pro: bool | None = None
|
| 36 |
+
can_pay: bool | None = None
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def missing_scopes(self) -> list[str]:
|
| 40 |
+
return sorted(REQUIRED_OAUTH_SCOPES - self.scopes)
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def is_expired(self) -> bool:
|
| 44 |
+
if self.expires_at is None:
|
| 45 |
+
return False
|
| 46 |
+
return self.expires_at <= datetime.now(timezone.utc)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _parse_scope(scope: Any) -> set[str]:
|
| 50 |
+
if not scope:
|
| 51 |
+
return set()
|
| 52 |
+
if isinstance(scope, str):
|
| 53 |
+
# HF OAuth scope strings are space-separated; be tolerant of comma lists.
|
| 54 |
+
return {part for chunk in scope.split(",") for part in chunk.split() if part}
|
| 55 |
+
if isinstance(scope, (list, tuple, set)):
|
| 56 |
+
return {str(part) for part in scope if part}
|
| 57 |
+
return {str(scope)}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _normalize_expires_at(value: Any) -> datetime | None:
|
| 61 |
+
if value is None:
|
| 62 |
+
return None
|
| 63 |
+
if isinstance(value, datetime):
|
| 64 |
+
if value.tzinfo is None:
|
| 65 |
+
return value.replace(tzinfo=timezone.utc)
|
| 66 |
+
return value.astimezone(timezone.utc)
|
| 67 |
+
try:
|
| 68 |
+
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
| 69 |
+
except Exception:
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _ctx_from_official_parser(request: Request) -> OAuthContext | None:
|
| 74 |
+
if parse_huggingface_oauth is None:
|
| 75 |
+
return None
|
| 76 |
+
info = parse_huggingface_oauth(request) # type: ignore[misc]
|
| 77 |
+
if info is None:
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
user_info = getattr(info, "user_info", None)
|
| 81 |
+
username = getattr(user_info, "preferred_username", None) or getattr(user_info, "name", None)
|
| 82 |
+
token = getattr(info, "access_token", None)
|
| 83 |
+
if not username or not token:
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
profile = {
|
| 87 |
+
"name": getattr(user_info, "name", None),
|
| 88 |
+
"preferred_username": getattr(user_info, "preferred_username", None),
|
| 89 |
+
"picture": getattr(user_info, "picture", None),
|
| 90 |
+
"email": getattr(user_info, "email", None),
|
| 91 |
+
"is_pro": getattr(user_info, "is_pro", None),
|
| 92 |
+
"can_pay": getattr(user_info, "can_pay", None),
|
| 93 |
+
}
|
| 94 |
+
return OAuthContext(
|
| 95 |
+
username=str(username),
|
| 96 |
+
token=str(token),
|
| 97 |
+
profile={k: v for k, v in profile.items() if v is not None},
|
| 98 |
+
scopes=_parse_scope(getattr(info, "scope", None)),
|
| 99 |
+
expires_at=_normalize_expires_at(getattr(info, "access_token_expires_at", None)),
|
| 100 |
+
is_pro=getattr(user_info, "is_pro", None),
|
| 101 |
+
can_pay=getattr(user_info, "can_pay", None),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _ctx_from_raw_session(request: Request) -> OAuthContext | None:
|
| 106 |
+
try:
|
| 107 |
+
oauth_info = request.session.get("oauth_info") # type: ignore[attr-defined]
|
| 108 |
+
except Exception:
|
| 109 |
+
oauth_info = None
|
| 110 |
+
if not oauth_info:
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
userinfo = oauth_info.get("userinfo") or {}
|
| 114 |
+
username = userinfo.get("preferred_username") or userinfo.get("username") or userinfo.get("name")
|
| 115 |
+
token = oauth_info.get("access_token")
|
| 116 |
+
if not username or not token:
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
profile = {
|
| 120 |
+
"name": userinfo.get("name"),
|
| 121 |
+
"preferred_username": userinfo.get("preferred_username") or userinfo.get("username"),
|
| 122 |
+
"picture": userinfo.get("picture"),
|
| 123 |
+
"email": userinfo.get("email"),
|
| 124 |
+
"is_pro": userinfo.get("isPro") or userinfo.get("is_pro"),
|
| 125 |
+
"can_pay": userinfo.get("canPay") or userinfo.get("can_pay"),
|
| 126 |
+
}
|
| 127 |
+
return OAuthContext(
|
| 128 |
+
username=str(username),
|
| 129 |
+
token=str(token),
|
| 130 |
+
profile={k: v for k, v in profile.items() if v is not None},
|
| 131 |
+
scopes=_parse_scope(oauth_info.get("scope")),
|
| 132 |
+
expires_at=_normalize_expires_at(oauth_info.get("expires_at")),
|
| 133 |
+
is_pro=profile.get("is_pro"),
|
| 134 |
+
can_pay=profile.get("can_pay"),
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def extract_oauth_context(request: Request) -> OAuthContext:
|
| 139 |
+
"""Extract and validate the signed-in HF user from the Gradio/FastAPI OAuth session.
|
| 140 |
+
|
| 141 |
+
Uses the official `huggingface_hub.parse_huggingface_oauth` helper first, then falls
|
| 142 |
+
back to the raw Gradio session shape for compatibility. The token is kept server-side
|
| 143 |
+
only and must never be returned by API responses.
|
| 144 |
+
"""
|
| 145 |
+
ctx = _ctx_from_official_parser(request) or _ctx_from_raw_session(request)
|
| 146 |
+
if ctx is None:
|
| 147 |
+
raise HTTPException(status_code=401, detail="Please sign in with Hugging Face first.")
|
| 148 |
+
if ctx.is_expired:
|
| 149 |
+
raise HTTPException(status_code=401, detail="Your Hugging Face OAuth session expired. Please sign in again.")
|
| 150 |
+
return ctx
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def public_oauth_context(ctx: OAuthContext) -> dict[str, Any]:
|
| 154 |
+
return {
|
| 155 |
+
"username": ctx.username,
|
| 156 |
+
"profile": {
|
| 157 |
+
"name": ctx.profile.get("name"),
|
| 158 |
+
"preferred_username": ctx.profile.get("preferred_username") or ctx.username,
|
| 159 |
+
"picture": ctx.profile.get("picture"),
|
| 160 |
+
"is_pro": ctx.is_pro,
|
| 161 |
+
"can_pay": ctx.can_pay,
|
| 162 |
+
},
|
| 163 |
+
"scopes": sorted(ctx.scopes),
|
| 164 |
+
"missing_scopes": ctx.missing_scopes,
|
| 165 |
+
"expires_at": ctx.expires_at.isoformat() if ctx.expires_at else None,
|
| 166 |
+
"authenticated": True,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def oauth_warning_messages(ctx: OAuthContext) -> list[str]:
|
| 171 |
+
warnings: list[str] = []
|
| 172 |
+
if ctx.missing_scopes:
|
| 173 |
+
warnings.append("Missing OAuth scopes: " + ", ".join(ctx.missing_scopes))
|
| 174 |
+
if ctx.can_pay is False:
|
| 175 |
+
warnings.append("No billing/payment method is visible through OAuth; fixed GPU hardware may require manual action.")
|
| 176 |
+
return warnings
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def verify_token_identity(ctx: OAuthContext) -> dict[str, Any]:
|
| 180 |
+
"""Best-effort diagnostics endpoint helper. Never returns the raw token."""
|
| 181 |
+
try:
|
| 182 |
+
info = HfApi(token=ctx.token).whoami()
|
| 183 |
+
name = info.get("name") or info.get("fullname") or info.get("preferred_username")
|
| 184 |
+
return {
|
| 185 |
+
"ok": True,
|
| 186 |
+
"oauth_username": ctx.username,
|
| 187 |
+
"whoami_name": name,
|
| 188 |
+
"matches_oauth_user": name == ctx.username if name else None,
|
| 189 |
+
"can_pay": ctx.can_pay,
|
| 190 |
+
"is_pro": ctx.is_pro,
|
| 191 |
+
"missing_scopes": ctx.missing_scopes,
|
| 192 |
+
}
|
| 193 |
+
except Exception as exc: # noqa: BLE001
|
| 194 |
+
return {
|
| 195 |
+
"ok": False,
|
| 196 |
+
"oauth_username": ctx.username,
|
| 197 |
+
"error": redact(str(exc)),
|
| 198 |
+
"missing_scopes": ctx.missing_scopes,
|
| 199 |
+
}
|
src/bucket.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from huggingface_hub import HfFileSystem, bucket_info, create_bucket
|
| 8 |
+
|
| 9 |
+
from .config import bucket_uri_from_source, normalize_bucket_name, settings, user_bucket_source
|
| 10 |
+
from .security import redact
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class RunPaths:
|
| 15 |
+
run_id: str
|
| 16 |
+
bucket_source: str
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def bucket_uri(self) -> str:
|
| 20 |
+
return bucket_uri_from_source(self.bucket_source)
|
| 21 |
+
|
| 22 |
+
@property
|
| 23 |
+
def root(self) -> str:
|
| 24 |
+
return f"{self.bucket_uri}/runs/{self.run_id}"
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def state(self) -> str:
|
| 28 |
+
return f"{self.root}/state.json"
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def events(self) -> str:
|
| 32 |
+
return f"{self.root}/events.jsonl"
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def report(self) -> str:
|
| 36 |
+
return f"{self.root}/report.md"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _fs(token: str | None = None) -> HfFileSystem:
|
| 40 |
+
return HfFileSystem(token=token)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def check_user_bucket(*, username: str, bucket_name: str | None = None, token: str | None = None) -> dict[str, Any]:
|
| 44 |
+
"""Return bucket readiness for the signed-in user without creating resources."""
|
| 45 |
+
bucket_source = user_bucket_source(username=username, bucket_name=bucket_name)
|
| 46 |
+
bucket_uri = bucket_uri_from_source(bucket_source)
|
| 47 |
+
try:
|
| 48 |
+
info = bucket_info(bucket_source, token=token)
|
| 49 |
+
return {
|
| 50 |
+
"ok": True,
|
| 51 |
+
"exists": True,
|
| 52 |
+
"bucket_source": bucket_source,
|
| 53 |
+
"bucket_uri": bucket_uri,
|
| 54 |
+
"name": getattr(info, "name", normalize_bucket_name(bucket_name)),
|
| 55 |
+
"private": getattr(info, "private", None),
|
| 56 |
+
}
|
| 57 |
+
except Exception as exc: # noqa: BLE001 - report readable status in UI
|
| 58 |
+
error = str(exc)
|
| 59 |
+
not_found = any(marker in error.lower() for marker in ["404", "not found", "repository not found", "bucket not found"])
|
| 60 |
+
return {
|
| 61 |
+
"ok": False,
|
| 62 |
+
"exists": False if not_found else None,
|
| 63 |
+
"bucket_source": bucket_source,
|
| 64 |
+
"bucket_uri": bucket_uri,
|
| 65 |
+
"error": error,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def create_user_bucket(*, username: str, bucket_name: str | None = None, token: str | None = None) -> dict[str, Any]:
|
| 70 |
+
"""Create the signed-in user's private run bucket, then return readiness."""
|
| 71 |
+
bucket_source = user_bucket_source(username=username, bucket_name=bucket_name)
|
| 72 |
+
try:
|
| 73 |
+
url = create_bucket(bucket_source, private=True, exist_ok=True, token=token)
|
| 74 |
+
except Exception as exc: # noqa: BLE001
|
| 75 |
+
return {
|
| 76 |
+
"ok": False,
|
| 77 |
+
"exists": None,
|
| 78 |
+
"bucket_source": bucket_source,
|
| 79 |
+
"bucket_uri": bucket_uri_from_source(bucket_source),
|
| 80 |
+
"error": str(exc),
|
| 81 |
+
}
|
| 82 |
+
status = check_user_bucket(username=username, bucket_name=bucket_name, token=token)
|
| 83 |
+
status["created_or_existing"] = True
|
| 84 |
+
status["create_url"] = str(url)
|
| 85 |
+
return status
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def assert_user_bucket_ready(*, username: str, bucket_name: str | None = None, token: str | None = None) -> dict[str, Any]:
|
| 89 |
+
"""Raise a clear error if the user's run bucket cannot be used."""
|
| 90 |
+
status = check_user_bucket(username=username, bucket_name=bucket_name, token=token)
|
| 91 |
+
if not status.get("ok"):
|
| 92 |
+
source = status.get("bucket_source") or user_bucket_source(username=username, bucket_name=bucket_name)
|
| 93 |
+
error = status.get("error") or "Bucket does not exist or is not accessible."
|
| 94 |
+
raise ValueError(
|
| 95 |
+
f"Run bucket `{source}` is not ready. Click 'Create private run bucket' first, "
|
| 96 |
+
f"or create it manually in Hugging Face Storage Buckets. Details: {error}"
|
| 97 |
+
)
|
| 98 |
+
return status
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def read_text(path: str, token: str | None = None) -> str | None:
|
| 102 |
+
fs = _fs(token)
|
| 103 |
+
try:
|
| 104 |
+
with fs.open(path, "r") as f:
|
| 105 |
+
return f.read()
|
| 106 |
+
except FileNotFoundError:
|
| 107 |
+
return None
|
| 108 |
+
except Exception as exc: # noqa: BLE001 - surface readable error in UI
|
| 109 |
+
return f"[Could not read {path}: {exc}]"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def read_json(path: str, token: str | None = None) -> dict[str, Any] | None:
|
| 113 |
+
content = read_text(path, token=token)
|
| 114 |
+
if not content or content.startswith("[Could not read"):
|
| 115 |
+
return None
|
| 116 |
+
try:
|
| 117 |
+
return json.loads(content)
|
| 118 |
+
except json.JSONDecodeError:
|
| 119 |
+
return {"_error": "Invalid JSON", "raw": redact(content)}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def read_events(run_id: str, *, bucket_source: str, token: str | None = None) -> list[dict[str, Any]]:
|
| 123 |
+
paths = RunPaths(run_id, bucket_source=bucket_source)
|
| 124 |
+
content = read_text(paths.events, token=token)
|
| 125 |
+
if not content:
|
| 126 |
+
return []
|
| 127 |
+
events: list[dict[str, Any]] = []
|
| 128 |
+
for line in content.splitlines():
|
| 129 |
+
line = line.strip()
|
| 130 |
+
if not line:
|
| 131 |
+
continue
|
| 132 |
+
try:
|
| 133 |
+
events.append(json.loads(line))
|
| 134 |
+
except json.JSONDecodeError:
|
| 135 |
+
events.append({"step": "parse_events", "status": "warning", "message": redact(line)})
|
| 136 |
+
return events
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _safe_read_json(path: str, token: str | None = None) -> dict[str, Any]:
|
| 140 |
+
return read_json(path, token=token) or {}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _safe_read_text(path: str, token: str | None = None) -> str:
|
| 144 |
+
return redact(read_text(path, token=token) or "")
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _list_run_files(
|
| 148 |
+
run_id: str,
|
| 149 |
+
*,
|
| 150 |
+
bucket_source: str,
|
| 151 |
+
token: str | None = None,
|
| 152 |
+
prefixes: tuple[str, ...] = ("generated", "tests", "logs", "artifacts", "traces", "repair"),
|
| 153 |
+
max_files: int = 120,
|
| 154 |
+
) -> list[dict[str, Any]]:
|
| 155 |
+
"""Return a compact, best-effort file index for a run.
|
| 156 |
+
|
| 157 |
+
The Run Explorer should not fail when a Bucket contains partial or old runs.
|
| 158 |
+
This function therefore treats every listing error as non-fatal and limits
|
| 159 |
+
recursion so the UI stays responsive even when traces are large.
|
| 160 |
+
"""
|
| 161 |
+
fs = _fs(token)
|
| 162 |
+
base = f"{bucket_uri_from_source(bucket_source)}/runs/{run_id}"
|
| 163 |
+
files: list[dict[str, Any]] = []
|
| 164 |
+
|
| 165 |
+
def walk(prefix: str, depth: int = 0) -> None:
|
| 166 |
+
if len(files) >= max_files or depth > 3:
|
| 167 |
+
return
|
| 168 |
+
path = f"{base}/{prefix}"
|
| 169 |
+
try:
|
| 170 |
+
entries = fs.ls(path, detail=True)
|
| 171 |
+
except Exception:
|
| 172 |
+
return
|
| 173 |
+
for entry in entries:
|
| 174 |
+
if len(files) >= max_files:
|
| 175 |
+
break
|
| 176 |
+
name = entry.get("name") if isinstance(entry, dict) else str(entry)
|
| 177 |
+
if not name:
|
| 178 |
+
continue
|
| 179 |
+
typ = str(entry.get("type") or "") if isinstance(entry, dict) else ""
|
| 180 |
+
size = entry.get("size") if isinstance(entry, dict) else None
|
| 181 |
+
rel = name.replace(base + "/", "", 1)
|
| 182 |
+
if typ == "directory":
|
| 183 |
+
walk(rel, depth + 1)
|
| 184 |
+
continue
|
| 185 |
+
files.append(
|
| 186 |
+
{
|
| 187 |
+
"path": rel,
|
| 188 |
+
"name": rel.split("/")[-1],
|
| 189 |
+
"size": size,
|
| 190 |
+
"url": f"https://huggingface.co/buckets/{bucket_source}/blob/main/runs/{run_id}/{rel}",
|
| 191 |
+
}
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
for prefix in prefixes:
|
| 195 |
+
walk(prefix)
|
| 196 |
+
return files
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def summarize_run_bundle(run_id: str, bundle: dict[str, Any], *, bucket_source: str) -> dict[str, Any]:
|
| 200 |
+
state = bundle.get("state") or {}
|
| 201 |
+
gate = bundle.get("inference_gate") or {}
|
| 202 |
+
smoke = bundle.get("generation_smoke") or {}
|
| 203 |
+
hardware = bundle.get("hardware_strategy") or {}
|
| 204 |
+
target_space = state.get("target_space") or ""
|
| 205 |
+
status = state.get("status") or gate.get("status") or smoke.get("status") or "unknown"
|
| 206 |
+
return {
|
| 207 |
+
"run_id": run_id,
|
| 208 |
+
"kind": state.get("kind") or "unknown",
|
| 209 |
+
"status": status,
|
| 210 |
+
"model_id": state.get("model_id") or state.get("model") or "",
|
| 211 |
+
"target_space": target_space,
|
| 212 |
+
"target_space_url": state.get("target_space_url") or (f"https://huggingface.co/spaces/{target_space}" if target_space else ""),
|
| 213 |
+
"job_url": state.get("job_url") or "",
|
| 214 |
+
"created_at": state.get("created_at") or "",
|
| 215 |
+
"updated_at": state.get("updated_at") or state.get("created_at") or "",
|
| 216 |
+
"selected_hardware": hardware.get("selected_hardware") or state.get("selected_hardware") or state.get("hardware") or "",
|
| 217 |
+
"manual_hardware_required": bool(gate.get("manual_hardware_required") or hardware.get("manual_action_required")),
|
| 218 |
+
"health_passed": bool((gate.get("implementation_signals") or {}).get("health_passed") or smoke.get("health_passed")),
|
| 219 |
+
"smoke_test_passed": bool(smoke.get("ok") or smoke.get("status") == "success"),
|
| 220 |
+
"latency_seconds": smoke.get("latency_seconds"),
|
| 221 |
+
"expected_output_type": smoke.get("expected_output_type") or state.get("expected_output_type") or "",
|
| 222 |
+
"artifacts_url": f"https://huggingface.co/buckets/{bucket_source}/tree/main/runs/{run_id}",
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def read_run_bundle(run_id: str, *, bucket_source: str, token: str | None = None) -> dict[str, Any]:
|
| 227 |
+
paths = RunPaths(run_id, bucket_source=bucket_source)
|
| 228 |
+
bundle = {
|
| 229 |
+
"paths": {
|
| 230 |
+
"root": paths.root,
|
| 231 |
+
"state": paths.state,
|
| 232 |
+
"events": paths.events,
|
| 233 |
+
"report": paths.report,
|
| 234 |
+
},
|
| 235 |
+
"state": read_json(paths.state, token=token) or {},
|
| 236 |
+
"events": read_events(run_id, bucket_source=bucket_source, token=token),
|
| 237 |
+
"report": _safe_read_text(paths.report, token=token),
|
| 238 |
+
"inference_gate": _safe_read_json(f"{paths.root}/inference_gate.json", token=token),
|
| 239 |
+
"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),
|
| 240 |
+
"hardware_strategy": _safe_read_json(f"{paths.root}/hardware_strategy.json", token=token),
|
| 241 |
+
"hardware_attempts": _safe_read_json(f"{paths.root}/hardware_attempts.json", token=token),
|
| 242 |
+
"technical_blockers": _safe_read_json(f"{paths.root}/TECHNICAL_BLOCKERS.json", token=token),
|
| 243 |
+
"model_analysis": _safe_read_json(f"{paths.root}/model_analysis.json", token=token),
|
| 244 |
+
"space_runtime": _safe_read_json(f"{paths.root}/space_runtime.json", token=token),
|
| 245 |
+
"files": _list_run_files(run_id, bucket_source=bucket_source, token=token),
|
| 246 |
+
}
|
| 247 |
+
bundle["summary"] = summarize_run_bundle(run_id, bundle, bucket_source=bucket_source)
|
| 248 |
+
return bundle
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _run_id_from_path(path: str) -> str:
|
| 252 |
+
return path.rstrip('/').split('/')[-1]
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def list_recent_runs(
|
| 256 |
+
*,
|
| 257 |
+
bucket_source: str,
|
| 258 |
+
token: str | None = None,
|
| 259 |
+
limit: int = 50,
|
| 260 |
+
query: str | None = None,
|
| 261 |
+
status: str | None = None,
|
| 262 |
+
) -> list[dict[str, Any]]:
|
| 263 |
+
"""List recent run summaries from a user's bucket.
|
| 264 |
+
|
| 265 |
+
Best effort: if a run folder is incomplete, return the information that is
|
| 266 |
+
available instead of failing the entire explorer.
|
| 267 |
+
"""
|
| 268 |
+
fs = _fs(token)
|
| 269 |
+
root = f"{bucket_uri_from_source(bucket_source)}/runs"
|
| 270 |
+
try:
|
| 271 |
+
entries = fs.ls(root, detail=True)
|
| 272 |
+
except FileNotFoundError:
|
| 273 |
+
return []
|
| 274 |
+
except Exception:
|
| 275 |
+
return []
|
| 276 |
+
|
| 277 |
+
runs: list[dict[str, Any]] = []
|
| 278 |
+
for entry in entries:
|
| 279 |
+
name = entry.get("name") if isinstance(entry, dict) else str(entry)
|
| 280 |
+
if not name:
|
| 281 |
+
continue
|
| 282 |
+
run_id = _run_id_from_path(name)
|
| 283 |
+
if run_id in {"runs", ""}:
|
| 284 |
+
continue
|
| 285 |
+
state = read_json(f"{root}/{run_id}/state.json", token=token) or {}
|
| 286 |
+
gate = read_json(f"{root}/{run_id}/inference_gate.json", token=token) or {}
|
| 287 |
+
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 {}
|
| 288 |
+
hardware = read_json(f"{root}/{run_id}/hardware_strategy.json", token=token) or {}
|
| 289 |
+
partial_bundle = {"state": state, "inference_gate": gate, "generation_smoke": smoke, "hardware_strategy": hardware}
|
| 290 |
+
item = summarize_run_bundle(run_id, partial_bundle, bucket_source=bucket_source)
|
| 291 |
+
item["bucket_source"] = bucket_source
|
| 292 |
+
haystack = " ".join(str(item.get(k, "")) for k in ["run_id", "model_id", "target_space", "status", "kind"]).lower()
|
| 293 |
+
if query and query.lower() not in haystack:
|
| 294 |
+
continue
|
| 295 |
+
if status and status not in {"all", ""} and item["status"] != status:
|
| 296 |
+
continue
|
| 297 |
+
runs.append(item)
|
| 298 |
+
|
| 299 |
+
runs.sort(key=lambda r: str(r.get("updated_at") or r.get("created_at") or r.get("run_id")), reverse=True)
|
| 300 |
+
return runs[: max(1, min(int(limit or 50), 200))]
|
src/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass(frozen=True)
|
| 8 |
+
class Settings:
|
| 9 |
+
"""Runtime configuration for the orchestrator Space."""
|
| 10 |
+
|
| 11 |
+
bucket_name: str = os.getenv("SPACE_FACTORY_BUCKET_NAME", "space-factory-runs")
|
| 12 |
+
bucket_mount: str = os.getenv("SPACE_FACTORY_BUCKET_MOUNT", "/output")
|
| 13 |
+
job_flavor: str = os.getenv("SPACE_FACTORY_JOB_FLAVOR", "cpu-basic")
|
| 14 |
+
job_timeout: str = os.getenv("SPACE_FACTORY_JOB_TIMEOUT", "30m")
|
| 15 |
+
job_image: str = os.getenv("SPACE_FACTORY_JOB_IMAGE", "python:3.12")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def normalize_bucket_name(value: str | None) -> str:
|
| 19 |
+
"""Return a safe bucket name, defaulting to the product bucket name."""
|
| 20 |
+
bucket = (value or settings.bucket_name).strip().strip("/")
|
| 21 |
+
if not bucket:
|
| 22 |
+
bucket = settings.bucket_name
|
| 23 |
+
if "/" in bucket:
|
| 24 |
+
raise ValueError("Bucket name must be a name like 'space-factory-runs', not owner/name.")
|
| 25 |
+
return bucket
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def user_bucket_source(*, username: str, bucket_name: str | None = None) -> str:
|
| 29 |
+
"""Return the per-user bucket source expected by HF Jobs volumes."""
|
| 30 |
+
clean_user = (username or "").strip()
|
| 31 |
+
if not clean_user:
|
| 32 |
+
raise ValueError("Missing username for per-user bucket source.")
|
| 33 |
+
return f"{clean_user}/{normalize_bucket_name(bucket_name)}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def bucket_uri_from_source(bucket_source: str) -> str:
|
| 37 |
+
return f"hf://buckets/{bucket_source}"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
settings = Settings()
|
src/jobs.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from huggingface_hub import Volume, fetch_job_logs, inspect_job, run_job
|
| 7 |
+
|
| 8 |
+
from .config import bucket_uri_from_source, user_bucket_source, settings
|
| 9 |
+
from .bucket import assert_user_bucket_ready
|
| 10 |
+
from .runs import make_run_id, utc_now_iso, validate_run_id
|
| 11 |
+
from .worker_payload import (
|
| 12 |
+
encoded_universal_model_card_worker_script,
|
| 13 |
+
encoded_validate_existing_space_worker_script,
|
| 14 |
+
python_decode_and_run_command,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
SPACE_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,95}$")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _base_env(*, run_id: str, username: str, bucket_source: str, worker_script_b64: str) -> dict[str, str]:
|
| 21 |
+
return {
|
| 22 |
+
"RUN_ID": run_id,
|
| 23 |
+
"HF_USERNAME": username or "unknown",
|
| 24 |
+
"BUCKET_SOURCE": bucket_source,
|
| 25 |
+
"OUTPUT_ROOT": settings.bucket_mount,
|
| 26 |
+
"WORKER_SCRIPT_B64": worker_script_b64,
|
| 27 |
+
"LAUNCHED_AT": utc_now_iso(),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _launch_job(*, token: str, env: dict[str, str], bucket_source: str, flavor: str | None = None, timeout: str | None = None) -> Any:
|
| 32 |
+
return run_job(
|
| 33 |
+
image=settings.job_image,
|
| 34 |
+
command=python_decode_and_run_command(),
|
| 35 |
+
flavor=flavor or settings.job_flavor,
|
| 36 |
+
timeout=timeout or settings.job_timeout,
|
| 37 |
+
env=env,
|
| 38 |
+
secrets={"HF_TOKEN": token},
|
| 39 |
+
volumes=[Volume(type="bucket", source=bucket_source, mount_path=settings.bucket_mount)],
|
| 40 |
+
token=token,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _job_result(job: Any, *, run_id: str, kind: str, bucket_source: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 45 |
+
payload: dict[str, Any] = {
|
| 46 |
+
"run_id": run_id,
|
| 47 |
+
"kind": kind,
|
| 48 |
+
"job_id": job.id,
|
| 49 |
+
"job_url": getattr(job, "url", None),
|
| 50 |
+
"status": getattr(getattr(job, "status", None), "stage", None),
|
| 51 |
+
"bucket_source": bucket_source,
|
| 52 |
+
"bucket_uri": bucket_uri_from_source(bucket_source),
|
| 53 |
+
}
|
| 54 |
+
if extra:
|
| 55 |
+
payload.update(extra)
|
| 56 |
+
return payload
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def normalize_target_space(*, username: str, target_slug: str | None, run_id: str) -> str:
|
| 60 |
+
"""Return `username/slug`, constrained to the signed-in user's namespace."""
|
| 61 |
+
slug = (target_slug or "").strip()
|
| 62 |
+
if not slug:
|
| 63 |
+
slug = f"space-factory-{run_id}".lower()[:80]
|
| 64 |
+
if "/" in slug:
|
| 65 |
+
namespace, repo = slug.split("/", 1)
|
| 66 |
+
if namespace != username:
|
| 67 |
+
raise ValueError("The target Space must be created in your own namespace.")
|
| 68 |
+
slug = repo
|
| 69 |
+
if not SPACE_SLUG_RE.match(slug):
|
| 70 |
+
raise ValueError("Invalid target Space name. Use letters, numbers, dots, underscores, or dashes.")
|
| 71 |
+
return f"{username}/{slug}"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _clean_repo_id(value: str | None, *, repo_kind: str) -> str:
|
| 75 |
+
cleaned = (value or "").strip()
|
| 76 |
+
cleaned = cleaned.replace("https://huggingface.co/spaces/", "")
|
| 77 |
+
cleaned = cleaned.replace("https://huggingface.co/", "")
|
| 78 |
+
cleaned = cleaned.strip("/")
|
| 79 |
+
if "/" not in cleaned:
|
| 80 |
+
raise ValueError(f"{repo_kind} must look like owner/name or a Hugging Face URL.")
|
| 81 |
+
return cleaned
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def launch_universal_model_card_job(
|
| 85 |
+
*,
|
| 86 |
+
token: str,
|
| 87 |
+
username: str,
|
| 88 |
+
target_slug: str | None = None,
|
| 89 |
+
model_id: str | None = None,
|
| 90 |
+
pi_model: str | None = None,
|
| 91 |
+
preferred_space_hardware: str | None = None,
|
| 92 |
+
fallback_space_hardware: str | None = None,
|
| 93 |
+
allow_fixed_gpu_fallback: bool = True,
|
| 94 |
+
implementation_mode: str | None = None,
|
| 95 |
+
run_id: str | None = None,
|
| 96 |
+
bucket_name: str | None = None,
|
| 97 |
+
) -> dict[str, Any]:
|
| 98 |
+
"""Launch the public product builder: model card → private Space attempt."""
|
| 99 |
+
if not token:
|
| 100 |
+
raise ValueError("Missing OAuth token. Please sign in with Hugging Face first.")
|
| 101 |
+
safe_run_id = validate_run_id(run_id) if run_id else make_run_id("universal")
|
| 102 |
+
target_space_id = normalize_target_space(username=username, target_slug=target_slug, run_id=safe_run_id)
|
| 103 |
+
clean_model_id = _clean_repo_id(model_id, repo_kind="Model ID")
|
| 104 |
+
bucket_source = user_bucket_source(username=username, bucket_name=bucket_name)
|
| 105 |
+
assert_user_bucket_ready(username=username, bucket_name=bucket_name, token=token)
|
| 106 |
+
|
| 107 |
+
env = _base_env(
|
| 108 |
+
run_id=safe_run_id,
|
| 109 |
+
username=username,
|
| 110 |
+
bucket_source=bucket_source,
|
| 111 |
+
worker_script_b64=encoded_universal_model_card_worker_script(),
|
| 112 |
+
)
|
| 113 |
+
env["TARGET_SPACE_ID"] = target_space_id
|
| 114 |
+
env["MODEL_ID"] = clean_model_id
|
| 115 |
+
env["PI_MODEL"] = (pi_model or "Qwen/Qwen3-Coder-Next").strip()
|
| 116 |
+
env["PREFERRED_SPACE_HARDWARE"] = (preferred_space_hardware or "zero-a10g").strip()
|
| 117 |
+
env["FALLBACK_SPACE_HARDWARE"] = (fallback_space_hardware or "l40sx1").strip()
|
| 118 |
+
env["ALLOW_FIXED_GPU_FALLBACK"] = "true" if allow_fixed_gpu_fallback else "false"
|
| 119 |
+
env["IMPLEMENTATION_MODE"] = (implementation_mode or "full-inference-gated").strip()
|
| 120 |
+
job = _launch_job(token=token, env=env, bucket_source=bucket_source, timeout="60m")
|
| 121 |
+
return _job_result(
|
| 122 |
+
job,
|
| 123 |
+
run_id=safe_run_id,
|
| 124 |
+
kind="universal_model_card_builder",
|
| 125 |
+
bucket_source=bucket_source,
|
| 126 |
+
extra={
|
| 127 |
+
"target_space": target_space_id,
|
| 128 |
+
"target_space_url": f"https://huggingface.co/spaces/{target_space_id}",
|
| 129 |
+
"model_id": clean_model_id,
|
| 130 |
+
"pi_model": env["PI_MODEL"],
|
| 131 |
+
"preferred_space_hardware": env["PREFERRED_SPACE_HARDWARE"],
|
| 132 |
+
"fallback_space_hardware": env["FALLBACK_SPACE_HARDWARE"],
|
| 133 |
+
"allow_fixed_gpu_fallback": allow_fixed_gpu_fallback,
|
| 134 |
+
"implementation_mode": env["IMPLEMENTATION_MODE"],
|
| 135 |
+
},
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def launch_validate_existing_space_job(
|
| 140 |
+
*,
|
| 141 |
+
token: str,
|
| 142 |
+
username: str,
|
| 143 |
+
target_space_id: str,
|
| 144 |
+
api_name: str | None = None,
|
| 145 |
+
test_args_json: str | None = None,
|
| 146 |
+
test_kwargs_json: str | None = None,
|
| 147 |
+
expected_output_type: str | None = None,
|
| 148 |
+
live_timeout_seconds: int = 1800,
|
| 149 |
+
run_id: str | None = None,
|
| 150 |
+
bucket_name: str | None = None,
|
| 151 |
+
) -> dict[str, Any]:
|
| 152 |
+
"""Launch the public product validator for an existing generated Space."""
|
| 153 |
+
if not token:
|
| 154 |
+
raise ValueError("Missing OAuth token. Please sign in with Hugging Face first.")
|
| 155 |
+
safe_run_id = validate_run_id(run_id) if run_id else make_run_id("validate")
|
| 156 |
+
target = _clean_repo_id(target_space_id, repo_kind="Target Space")
|
| 157 |
+
namespace, _ = target.split("/", 1)
|
| 158 |
+
if namespace != username:
|
| 159 |
+
raise ValueError("For this version, target Space validation is limited to your own namespace.")
|
| 160 |
+
bucket_source = user_bucket_source(username=username, bucket_name=bucket_name)
|
| 161 |
+
assert_user_bucket_ready(username=username, bucket_name=bucket_name, token=token)
|
| 162 |
+
env = _base_env(
|
| 163 |
+
run_id=safe_run_id,
|
| 164 |
+
username=username,
|
| 165 |
+
bucket_source=bucket_source,
|
| 166 |
+
worker_script_b64=encoded_validate_existing_space_worker_script(),
|
| 167 |
+
)
|
| 168 |
+
env["TARGET_SPACE_ID"] = target
|
| 169 |
+
env["API_NAME"] = (api_name or "/generate").strip()
|
| 170 |
+
env["TEST_ARGS_JSON"] = (test_args_json or '["a cinematic robot cat astronaut, detailed, studio lighting"]').strip()
|
| 171 |
+
env["TEST_KWARGS_JSON"] = (test_kwargs_json or "{}").strip()
|
| 172 |
+
env["EXPECTED_OUTPUT_TYPE"] = (expected_output_type or "image").strip()
|
| 173 |
+
env["LIVE_TIMEOUT_SECONDS"] = str(int(live_timeout_seconds or 1800))
|
| 174 |
+
job = _launch_job(token=token, env=env, bucket_source=bucket_source, timeout="60m")
|
| 175 |
+
return _job_result(
|
| 176 |
+
job,
|
| 177 |
+
run_id=safe_run_id,
|
| 178 |
+
kind="validate_existing_space",
|
| 179 |
+
bucket_source=bucket_source,
|
| 180 |
+
extra={
|
| 181 |
+
"target_space": target,
|
| 182 |
+
"target_space_url": f"https://huggingface.co/spaces/{target}",
|
| 183 |
+
"api_name": env["API_NAME"],
|
| 184 |
+
"expected_output_type": env["EXPECTED_OUTPUT_TYPE"],
|
| 185 |
+
"test_args_json": env["TEST_ARGS_JSON"],
|
| 186 |
+
"test_kwargs_json": env["TEST_KWARGS_JSON"],
|
| 187 |
+
},
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def inspect_job_safe(job_id: str, token: str | None = None) -> dict[str, Any]:
|
| 192 |
+
if not job_id:
|
| 193 |
+
return {"error": "Missing job_id"}
|
| 194 |
+
try:
|
| 195 |
+
info = inspect_job(job_id=job_id, token=token)
|
| 196 |
+
status = getattr(info, "status", None)
|
| 197 |
+
return {
|
| 198 |
+
"id": info.id,
|
| 199 |
+
"url": getattr(info, "url", None),
|
| 200 |
+
"stage": getattr(status, "stage", None),
|
| 201 |
+
"message": getattr(status, "message", None),
|
| 202 |
+
"flavor": getattr(info, "flavor", None),
|
| 203 |
+
"created_at": str(getattr(info, "created_at", "")),
|
| 204 |
+
"started_at": str(getattr(info, "started_at", "")),
|
| 205 |
+
"finished_at": str(getattr(info, "finished_at", "")),
|
| 206 |
+
}
|
| 207 |
+
except Exception as exc: # noqa: BLE001
|
| 208 |
+
return {"error": str(exc)}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def fetch_recent_logs_safe(job_id: str, token: str | None = None, max_lines: int = 120) -> str:
|
| 212 |
+
if not job_id:
|
| 213 |
+
return ""
|
| 214 |
+
try:
|
| 215 |
+
logs = list(fetch_job_logs(job_id=job_id, token=token))
|
| 216 |
+
return "\n".join(str(line).rstrip("\n") for line in logs[-max_lines:])
|
| 217 |
+
except Exception as exc: # noqa: BLE001
|
| 218 |
+
return f"Could not fetch job logs: {exc}"
|
src/progress.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
STEP_ORDER = [
|
| 8 |
+
"bucket_ready",
|
| 9 |
+
"job_launched",
|
| 10 |
+
"dependencies",
|
| 11 |
+
"auth",
|
| 12 |
+
"model_analysis",
|
| 13 |
+
"workspace",
|
| 14 |
+
"node",
|
| 15 |
+
"pi_install",
|
| 16 |
+
"pi_config",
|
| 17 |
+
"pi_run",
|
| 18 |
+
"create_space",
|
| 19 |
+
"upload_files",
|
| 20 |
+
"hardware",
|
| 21 |
+
"api_validation",
|
| 22 |
+
"inference_gate",
|
| 23 |
+
"report_write",
|
| 24 |
+
"done",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
STEP_LABELS = {
|
| 28 |
+
"bucket_ready": "Bucket ready",
|
| 29 |
+
"job_launched": "Job launched",
|
| 30 |
+
"dependencies": "Dependencies",
|
| 31 |
+
"auth": "Authenticated",
|
| 32 |
+
"model_analysis": "Model analysis",
|
| 33 |
+
"workspace": "Workspace",
|
| 34 |
+
"node": "Node/npm",
|
| 35 |
+
"pi_install": "Pi installed",
|
| 36 |
+
"pi_config": "Pi configured",
|
| 37 |
+
"pi_run": "Pi running",
|
| 38 |
+
"create_space": "Create Space",
|
| 39 |
+
"upload_files": "Upload files",
|
| 40 |
+
"hardware": "Hardware request",
|
| 41 |
+
"api_validation": "API validation",
|
| 42 |
+
"inference_gate": "Gate",
|
| 43 |
+
"report_write": "Report",
|
| 44 |
+
"done": "Done",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
STEP_PROGRESS = {
|
| 48 |
+
"bucket_ready": 5,
|
| 49 |
+
"job_launched": 8,
|
| 50 |
+
"dependencies": 12,
|
| 51 |
+
"auth": 16,
|
| 52 |
+
"model_analysis": 22,
|
| 53 |
+
"workspace": 28,
|
| 54 |
+
"node": 34,
|
| 55 |
+
"pi_install": 40,
|
| 56 |
+
"pi_config": 44,
|
| 57 |
+
"pi_run": 62,
|
| 58 |
+
"create_space": 72,
|
| 59 |
+
"upload_files": 80,
|
| 60 |
+
"hardware": 86,
|
| 61 |
+
"api_validation": 92,
|
| 62 |
+
"inference_gate": 96,
|
| 63 |
+
"report_write": 98,
|
| 64 |
+
"done": 100,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
STEP_ALIASES = {
|
| 68 |
+
"bootstrap": "job_launched",
|
| 69 |
+
"dependencies": "dependencies",
|
| 70 |
+
"auth": "auth",
|
| 71 |
+
"model_analysis": "model_analysis",
|
| 72 |
+
"workspace": "workspace",
|
| 73 |
+
"node": "node",
|
| 74 |
+
"pi_install": "pi_install",
|
| 75 |
+
"pi_config": "pi_config",
|
| 76 |
+
"pi_run": "pi_run",
|
| 77 |
+
"create_space": "create_space",
|
| 78 |
+
"upload_files": "upload_files",
|
| 79 |
+
"hardware_preferred": "hardware",
|
| 80 |
+
"hardware_fallback": "hardware",
|
| 81 |
+
"hardware": "hardware",
|
| 82 |
+
"api_validation": "api_validation",
|
| 83 |
+
"inference_gate": "inference_gate",
|
| 84 |
+
"report_write": "report_write",
|
| 85 |
+
"done": "done",
|
| 86 |
+
"failure": "done",
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
DONE_STATUSES = {"success", "done", "completed", "passed", "full_inference_success", "full_inference_candidate_health_passed", "manual_hardware_required", "technical_blocker"}
|
| 90 |
+
RUNNING_STATUSES = {"started", "running", "waiting"}
|
| 91 |
+
FAILED_STATUSES = {"failed", "error"}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _parse_ts(ts: str | None) -> datetime | None:
|
| 95 |
+
if not ts:
|
| 96 |
+
return None
|
| 97 |
+
try:
|
| 98 |
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
| 99 |
+
except Exception:
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _canonical_step(step: str | None) -> str | None:
|
| 104 |
+
if not step:
|
| 105 |
+
return None
|
| 106 |
+
return STEP_ALIASES.get(step, step if step in STEP_PROGRESS else None)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def progress_from_events(events: list[dict[str, Any]] | None, *, state: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 110 |
+
"""Build a stable UI progress model from worker events.jsonl + optional state.json."""
|
| 111 |
+
events = events or []
|
| 112 |
+
state = state or {}
|
| 113 |
+
step_status = {step: "pending" for step in STEP_ORDER}
|
| 114 |
+
last_event = None
|
| 115 |
+
current_step = "job_launched"
|
| 116 |
+
terminal_status = None
|
| 117 |
+
first_ts = None
|
| 118 |
+
last_ts = None
|
| 119 |
+
|
| 120 |
+
for event in events:
|
| 121 |
+
if not isinstance(event, dict):
|
| 122 |
+
continue
|
| 123 |
+
last_event = event
|
| 124 |
+
step = _canonical_step(event.get("step"))
|
| 125 |
+
status = str(event.get("status") or "").lower()
|
| 126 |
+
ts = _parse_ts(event.get("ts"))
|
| 127 |
+
if ts and not first_ts:
|
| 128 |
+
first_ts = ts
|
| 129 |
+
if ts:
|
| 130 |
+
last_ts = ts
|
| 131 |
+
if not step:
|
| 132 |
+
continue
|
| 133 |
+
current_step = step
|
| 134 |
+
if status in FAILED_STATUSES:
|
| 135 |
+
step_status[step] = "failed"
|
| 136 |
+
terminal_status = "failed"
|
| 137 |
+
elif status in RUNNING_STATUSES:
|
| 138 |
+
if step_status.get(step) != "done":
|
| 139 |
+
step_status[step] = "running"
|
| 140 |
+
elif status in DONE_STATUSES or status:
|
| 141 |
+
step_status[step] = "done"
|
| 142 |
+
|
| 143 |
+
if step == "done" and status:
|
| 144 |
+
terminal_status = status
|
| 145 |
+
|
| 146 |
+
# Mark all previous steps as done up to the current running/done step.
|
| 147 |
+
current_index = STEP_ORDER.index(current_step) if current_step in STEP_ORDER else 0
|
| 148 |
+
for step in STEP_ORDER[:current_index]:
|
| 149 |
+
if step_status[step] == "pending":
|
| 150 |
+
step_status[step] = "done"
|
| 151 |
+
|
| 152 |
+
if terminal_status and terminal_status != "failed":
|
| 153 |
+
current_step = "done"
|
| 154 |
+
for step in STEP_ORDER:
|
| 155 |
+
if step_status[step] != "failed":
|
| 156 |
+
step_status[step] = "done"
|
| 157 |
+
|
| 158 |
+
status_from_state = state.get("status") or state.get("gate_status")
|
| 159 |
+
overall_status = terminal_status or status_from_state or ("running" if events else "not_started")
|
| 160 |
+
|
| 161 |
+
if overall_status in {"failed", "error"}:
|
| 162 |
+
progress = max(STEP_PROGRESS.get(current_step, 8), 8)
|
| 163 |
+
elif current_step == "done" or overall_status in DONE_STATUSES:
|
| 164 |
+
progress = 100
|
| 165 |
+
else:
|
| 166 |
+
progress = STEP_PROGRESS.get(current_step, 8)
|
| 167 |
+
if step_status.get(current_step) == "running":
|
| 168 |
+
previous = STEP_ORDER[max(0, current_index - 1)] if current_index > 0 else current_step
|
| 169 |
+
progress = max(STEP_PROGRESS.get(previous, 0) + 2, progress - 8)
|
| 170 |
+
|
| 171 |
+
now = datetime.now(timezone.utc)
|
| 172 |
+
elapsed = int(((last_ts or now) - first_ts).total_seconds()) if first_ts else 0
|
| 173 |
+
if not first_ts and state.get("created_at"):
|
| 174 |
+
created = _parse_ts(str(state.get("created_at")))
|
| 175 |
+
if created:
|
| 176 |
+
elapsed = int((now - created).total_seconds())
|
| 177 |
+
|
| 178 |
+
timeline = [
|
| 179 |
+
{"step": step, "label": STEP_LABELS[step], "status": step_status[step]}
|
| 180 |
+
for step in STEP_ORDER
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
last_message = None
|
| 184 |
+
if last_event:
|
| 185 |
+
last_message = last_event.get("message") or last_event.get("step")
|
| 186 |
+
|
| 187 |
+
return {
|
| 188 |
+
"status": overall_status,
|
| 189 |
+
"progress": int(max(0, min(100, progress))),
|
| 190 |
+
"current_step": current_step,
|
| 191 |
+
"current_step_label": STEP_LABELS.get(current_step, current_step),
|
| 192 |
+
"last_event": last_message or "No events yet",
|
| 193 |
+
"elapsed_seconds": max(0, elapsed),
|
| 194 |
+
"eta_seconds": None,
|
| 195 |
+
"timeline": timeline,
|
| 196 |
+
"last_event_raw": last_event or {},
|
| 197 |
+
}
|
src/runs.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
RUN_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,80}$")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def utc_now_iso() -> str:
|
| 11 |
+
return datetime.now(timezone.utc).isoformat()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def make_run_id(prefix: str = "run") -> str:
|
| 15 |
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
| 16 |
+
return f"{prefix}-{stamp}-{uuid.uuid4().hex[:8]}"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def validate_run_id(run_id: str) -> str:
|
| 20 |
+
cleaned = (run_id or "").strip()
|
| 21 |
+
if not RUN_ID_RE.match(cleaned):
|
| 22 |
+
raise ValueError("Invalid run_id. Use 3-80 characters: letters, numbers, dots, underscores or dashes.")
|
| 23 |
+
return cleaned
|
src/security.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
SECRET_PATTERNS = [
|
| 6 |
+
re.compile(r"hf_[A-Za-z0-9_\-]{20,}"),
|
| 7 |
+
re.compile(r"Bearer\s+[A-Za-z0-9_\.\-]+", re.IGNORECASE),
|
| 8 |
+
re.compile(r"(HF_TOKEN|OAUTH_TOKEN|ACCESS_TOKEN|AUTHORIZATION|PASSWORD|SECRET)\s*[:=]\s*[^\s]+", re.IGNORECASE),
|
| 9 |
+
]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def redact(text: str | None) -> str:
|
| 13 |
+
"""Best-effort redaction for logs/reports shown in the UI.
|
| 14 |
+
|
| 15 |
+
This is intentionally conservative. It is not a complete DLP system,
|
| 16 |
+
but it protects against obvious token leaks in first-version outputs.
|
| 17 |
+
"""
|
| 18 |
+
if not text:
|
| 19 |
+
return ""
|
| 20 |
+
|
| 21 |
+
redacted = text
|
| 22 |
+
for pattern in SECRET_PATTERNS:
|
| 23 |
+
redacted = pattern.sub("[REDACTED]", redacted)
|
| 24 |
+
return redacted
|
src/worker_payload.py
ADDED
|
@@ -0,0 +1,1428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import textwrap
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _encode(script: str) -> str:
|
| 8 |
+
return base64.b64encode(script.encode("utf-8")).decode("ascii")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
UNIVERSAL_MODEL_CARD_WORKER_SCRIPT = r'''
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import shutil
|
| 17 |
+
import subprocess
|
| 18 |
+
import sys
|
| 19 |
+
import time
|
| 20 |
+
from datetime import datetime, timezone
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from textwrap import dedent
|
| 23 |
+
|
| 24 |
+
TARGET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,95}/[A-Za-z0-9][A-Za-z0-9._-]{1,95}$")
|
| 25 |
+
GIST_URL = "https://gist.github.com/gary149/2aba2962375fa9ca56bb9ef53f00b73d"
|
| 26 |
+
DEFAULT_MODEL_ID = "sshleifer/tiny-gpt2"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def now():
|
| 30 |
+
return datetime.now(timezone.utc).isoformat()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def write_json(path: Path, payload: dict):
|
| 34 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def append_event(path: Path, step: str, status: str, message: str, data: dict | None = None):
|
| 39 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
event = {"ts": now(), "step": step, "status": status, "message": message, "data": data or {}}
|
| 41 |
+
line = json.dumps(event, ensure_ascii=False)
|
| 42 |
+
with path.open("a", encoding="utf-8") as f:
|
| 43 |
+
f.write(line + "\n")
|
| 44 |
+
print(line, flush=True)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def redact_text(text: str | None) -> str:
|
| 48 |
+
if not text:
|
| 49 |
+
return ""
|
| 50 |
+
value = text
|
| 51 |
+
for secret_name in ["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"]:
|
| 52 |
+
secret = os.environ.get(secret_name)
|
| 53 |
+
if secret:
|
| 54 |
+
value = value.replace(secret, "[REDACTED]")
|
| 55 |
+
value = re.sub(r"Bearer\s+[A-Za-z0-9_\-.=]+", "Bearer [REDACTED]", value)
|
| 56 |
+
value = re.sub(r"hf_[A-Za-z0-9_\-]{10,}", "hf_[REDACTED]", value)
|
| 57 |
+
return value
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def safe_details(details: dict | None) -> dict:
|
| 61 |
+
if not details:
|
| 62 |
+
return {}
|
| 63 |
+
try:
|
| 64 |
+
return json.loads(redact_text(json.dumps(details, ensure_ascii=False)))
|
| 65 |
+
except Exception:
|
| 66 |
+
return {"redacted_details": redact_text(str(details))[-4000:]}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def fail(run_dir: Path, events_path: Path, message: str, details: dict | None = None, status: str = "failed"):
|
| 70 |
+
safe = safe_details(details)
|
| 71 |
+
append_event(events_path, "failure", "failed", message, safe)
|
| 72 |
+
write_json(run_dir / "state.json", {
|
| 73 |
+
"run_id": os.environ.get("RUN_ID"),
|
| 74 |
+
"kind": "universal_model_card_builder",
|
| 75 |
+
"status": status,
|
| 76 |
+
"message": message,
|
| 77 |
+
"updated_at": now(),
|
| 78 |
+
"details": safe,
|
| 79 |
+
})
|
| 80 |
+
report = f"""# Agentic Space Factory — model Article Reproduction Report
|
| 81 |
+
|
| 82 |
+
Status: **{status}**
|
| 83 |
+
|
| 84 |
+
{message}
|
| 85 |
+
|
| 86 |
+
```json
|
| 87 |
+
{json.dumps(safe, indent=2, ensure_ascii=False)}
|
| 88 |
+
```
|
| 89 |
+
"""
|
| 90 |
+
(run_dir / "report.md").write_text(report, encoding="utf-8")
|
| 91 |
+
raise SystemExit(1)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def run_cmd(cmd: list[str], *, cwd: Path | None = None, env: dict | None = None, timeout: int = 600):
|
| 95 |
+
result = subprocess.run(cmd, cwd=str(cwd) if cwd else None, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
| 96 |
+
return result.returncode, redact_text(result.stdout)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def install_python_deps(events_path: Path):
|
| 100 |
+
append_event(events_path, "dependencies", "started", "Installing Python worker dependencies")
|
| 101 |
+
code, out = run_cmd([sys.executable, "-m", "pip", "install", "-q", "--upgrade", "huggingface_hub>=1.0.0", "gradio_client>=2.0.0", "requests>=2.31.0"], timeout=600)
|
| 102 |
+
if code != 0:
|
| 103 |
+
append_event(events_path, "dependencies", "failed", "Python dependency installation failed", {"output_tail": out[-4000:]})
|
| 104 |
+
raise RuntimeError(out)
|
| 105 |
+
append_event(events_path, "dependencies", "success", "Python worker dependencies installed")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def ensure_node(events_path: Path):
|
| 109 |
+
node = shutil.which("node")
|
| 110 |
+
npm = shutil.which("npm")
|
| 111 |
+
if node and npm:
|
| 112 |
+
_, node_v = run_cmd([node, "--version"], timeout=30)
|
| 113 |
+
_, npm_v = run_cmd([npm, "--version"], timeout=30)
|
| 114 |
+
append_event(events_path, "node", "success", "Node/npm already available", {"node": node_v.strip(), "npm": npm_v.strip()})
|
| 115 |
+
return
|
| 116 |
+
append_event(events_path, "node", "started", "Installing nodejs/npm through apt-get")
|
| 117 |
+
code, out = run_cmd(["bash", "-lc", "apt-get update -qq && apt-get install -y -qq nodejs npm"], timeout=600)
|
| 118 |
+
if code != 0:
|
| 119 |
+
append_event(events_path, "node", "failed", "Could not install nodejs/npm", {"output_tail": out[-4000:]})
|
| 120 |
+
raise RuntimeError(out)
|
| 121 |
+
append_event(events_path, "node", "success", "Installed nodejs/npm")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def install_pi(events_path: Path):
|
| 125 |
+
ensure_node(events_path)
|
| 126 |
+
append_event(events_path, "pi_install", "started", "Installing Pi coding agent from npm")
|
| 127 |
+
code, out = run_cmd(["npm", "install", "-g", "@mariozechner/pi-coding-agent"], timeout=900)
|
| 128 |
+
if code != 0:
|
| 129 |
+
append_event(events_path, "pi_install", "failed", "Pi npm installation failed", {"output_tail": out[-4000:]})
|
| 130 |
+
raise RuntimeError(out)
|
| 131 |
+
code, version = run_cmd(["pi", "--version"], timeout=60)
|
| 132 |
+
append_event(events_path, "pi_install", "success", "Pi installed", {"version_output": version.strip()[-300:]})
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def configure_pi(events_path: Path, model: str):
|
| 136 |
+
pi_dir = Path.home() / ".pi" / "agent"
|
| 137 |
+
pi_dir.mkdir(parents=True, exist_ok=True)
|
| 138 |
+
(pi_dir / "auth.json").write_text(json.dumps({"huggingface": {"type": "api_key", "key": os.environ.get("HF_TOKEN", "")}}, indent=2), encoding="utf-8")
|
| 139 |
+
(pi_dir / "settings.json").write_text(json.dumps({"model": model, "provider": "huggingface", "autoRun": True, "autoApply": True}, indent=2), encoding="utf-8")
|
| 140 |
+
append_event(events_path, "pi_config", "success", "Configured Pi", {"model": model})
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def collect_pi_traces(run_dir: Path, events_path: Path):
|
| 144 |
+
traces_dir = Path.home() / ".pi" / "agent" / "sessions"
|
| 145 |
+
raw_dir = run_dir / "traces" / "raw"
|
| 146 |
+
redacted_dir = run_dir / "traces" / "redacted"
|
| 147 |
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
| 148 |
+
redacted_dir.mkdir(parents=True, exist_ok=True)
|
| 149 |
+
count = 0
|
| 150 |
+
if traces_dir.exists():
|
| 151 |
+
for path in traces_dir.rglob("*.jsonl"):
|
| 152 |
+
rel = path.relative_to(traces_dir)
|
| 153 |
+
target_raw = raw_dir / rel
|
| 154 |
+
target_raw.parent.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
| 156 |
+
target_raw.write_text(text, encoding="utf-8")
|
| 157 |
+
target_redacted = redacted_dir / rel
|
| 158 |
+
target_redacted.parent.mkdir(parents=True, exist_ok=True)
|
| 159 |
+
target_redacted.write_text(redact_text(text), encoding="utf-8")
|
| 160 |
+
count += 1
|
| 161 |
+
append_event(events_path, "traces", "success", "Collected Pi traces", {"count": count})
|
| 162 |
+
return count
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def sanitize_model_id(model_id: str) -> str:
|
| 166 |
+
model_id = (model_id or DEFAULT_MODEL_ID).strip().replace("https://huggingface.co/", "")
|
| 167 |
+
model_id = model_id.split("?", 1)[0].strip("/")
|
| 168 |
+
if not re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", model_id):
|
| 169 |
+
raise ValueError("MODEL_ID must look like owner/model-name")
|
| 170 |
+
return model_id
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def make_gradio_client(target_space_id: str, token: str):
|
| 174 |
+
import inspect
|
| 175 |
+
from gradio_client import Client
|
| 176 |
+
params = inspect.signature(Client).parameters
|
| 177 |
+
if "token" in params:
|
| 178 |
+
return Client(target_space_id, token=token)
|
| 179 |
+
if "hf_token" in params:
|
| 180 |
+
return Client(target_space_id, hf_token=token)
|
| 181 |
+
if "api_key" in params:
|
| 182 |
+
return Client(target_space_id, api_key=token)
|
| 183 |
+
if "headers" in params:
|
| 184 |
+
return Client(target_space_id, headers={"Authorization": f"Bearer {token}"})
|
| 185 |
+
return Client(target_space_id)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def api_names_from_schema(schema) -> list[str]:
|
| 189 |
+
names: list[str] = []
|
| 190 |
+
if isinstance(schema, dict):
|
| 191 |
+
endpoints = schema.get("named_endpoints") or schema.get("endpoints") or {}
|
| 192 |
+
if isinstance(endpoints, dict):
|
| 193 |
+
for key, value in endpoints.items():
|
| 194 |
+
if isinstance(key, str) and key.startswith("/"):
|
| 195 |
+
names.append(key)
|
| 196 |
+
if isinstance(value, dict):
|
| 197 |
+
api_name = value.get("api_name")
|
| 198 |
+
if isinstance(api_name, str) and api_name.startswith("/"):
|
| 199 |
+
names.append(api_name)
|
| 200 |
+
if isinstance(schema.get("dependencies"), list):
|
| 201 |
+
for dep in schema["dependencies"]:
|
| 202 |
+
if isinstance(dep, dict):
|
| 203 |
+
api_name = dep.get("api_name")
|
| 204 |
+
if isinstance(api_name, str):
|
| 205 |
+
names.append(api_name if api_name.startswith("/") else f"/{api_name}")
|
| 206 |
+
return list(dict.fromkeys(names))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def space_subdomain_url(target_space_id: str) -> str:
|
| 210 |
+
owner, name = target_space_id.split("/", 1)
|
| 211 |
+
# This matches the common Spaces app URL pattern. Keep conservative: our
|
| 212 |
+
# generated slugs are ASCII and hyphen-friendly.
|
| 213 |
+
return f"https://{owner}-{name}.hf.space".replace("_", "-").lower()
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def runtime_to_dict(runtime) -> dict:
|
| 217 |
+
payload = {}
|
| 218 |
+
for attr in ["stage", "hardware", "requested_hardware", "sleep_time", "storage", "gc_timeout"]:
|
| 219 |
+
value = getattr(runtime, attr, None)
|
| 220 |
+
payload[attr] = getattr(value, "value", value)
|
| 221 |
+
return {k: str(v) if v is not None else None for k, v in payload.items()}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def write_space_runtime(api, target_space_id: str, token: str, run_dir: Path, events_path: Path, attempt: int | None = None) -> dict:
|
| 225 |
+
try:
|
| 226 |
+
runtime = api.get_space_runtime(repo_id=target_space_id, token=token)
|
| 227 |
+
payload = runtime_to_dict(runtime)
|
| 228 |
+
payload["attempt"] = attempt
|
| 229 |
+
write_json(run_dir / "space_runtime.json", payload)
|
| 230 |
+
return payload
|
| 231 |
+
except Exception as exc:
|
| 232 |
+
payload = {"error": str(exc)[:2000], "attempt": attempt}
|
| 233 |
+
write_json(run_dir / "space_runtime.json", payload)
|
| 234 |
+
append_event(events_path, "space_runtime", "warning", "Could not fetch Space runtime", payload)
|
| 235 |
+
return payload
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_path: Path):
|
| 239 |
+
logs_dir = run_dir / "logs"
|
| 240 |
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
| 241 |
+
env = os.environ.copy()
|
| 242 |
+
env["HF_TOKEN"] = token
|
| 243 |
+
commands = {
|
| 244 |
+
"space_logs_runtime.txt": ["hf", "spaces", "logs", target_space_id],
|
| 245 |
+
"space_logs_build.txt": ["hf", "spaces", "logs", target_space_id, "--build"],
|
| 246 |
+
}
|
| 247 |
+
written = []
|
| 248 |
+
for filename, cmd in commands.items():
|
| 249 |
+
try:
|
| 250 |
+
code, out = run_cmd(cmd, env=env, timeout=75)
|
| 251 |
+
(logs_dir / filename).write_text(out, encoding="utf-8")
|
| 252 |
+
written.append({"file": filename, "returncode": code, "tail": out[-1000:]})
|
| 253 |
+
except Exception as exc:
|
| 254 |
+
written.append({"file": filename, "error": str(exc)[:1000]})
|
| 255 |
+
append_event(events_path, "space_logs", "success", "Collected best-effort Space logs", {"files": written})
|
| 256 |
+
return written
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def validate_http_health(target_space_id: str, token: str, run_dir: Path, events_path: Path, attempt: int):
|
| 260 |
+
import requests
|
| 261 |
+
base_url = space_subdomain_url(target_space_id)
|
| 262 |
+
url = base_url.rstrip("/") + "/health"
|
| 263 |
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json,text/plain,*/*"}
|
| 264 |
+
response = requests.get(url, headers=headers, timeout=20)
|
| 265 |
+
payload = {
|
| 266 |
+
"status": "success" if response.ok else "failed",
|
| 267 |
+
"attempt": attempt,
|
| 268 |
+
"url": url,
|
| 269 |
+
"status_code": response.status_code,
|
| 270 |
+
"content_type": response.headers.get("content-type"),
|
| 271 |
+
"text": response.text[:2000],
|
| 272 |
+
}
|
| 273 |
+
if response.ok:
|
| 274 |
+
try:
|
| 275 |
+
payload["json"] = response.json()
|
| 276 |
+
except Exception:
|
| 277 |
+
pass
|
| 278 |
+
write_json(run_dir / "tests" / "http_health.json", payload)
|
| 279 |
+
write_json(run_dir / "tests" / "test_result.json", payload | {"validator": "http_get_health"})
|
| 280 |
+
append_event(events_path, "api_validation", "success", "HTTP /health validation passed", {"attempt": attempt, "url": url, "status_code": response.status_code})
|
| 281 |
+
return payload | {"validator": "http_get_health"}
|
| 282 |
+
raise RuntimeError(f"HTTP /health returned {response.status_code}: {response.text[:500]}")
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def validate_gradio_api(target_space_id: str, token: str, run_dir: Path, events_path: Path, attempt: int):
|
| 286 |
+
client = make_gradio_client(target_space_id, token)
|
| 287 |
+
schema = client.view_api(return_format="dict")
|
| 288 |
+
write_json(run_dir / "tests" / "api_schema.json", schema if isinstance(schema, dict) else {"schema": str(schema)})
|
| 289 |
+
discovered = api_names_from_schema(schema)
|
| 290 |
+
candidates = []
|
| 291 |
+
for name in ["/health", "/predict", "/greet"] + discovered:
|
| 292 |
+
if name not in candidates:
|
| 293 |
+
candidates.append(name)
|
| 294 |
+
errors = []
|
| 295 |
+
for api_name in candidates:
|
| 296 |
+
try:
|
| 297 |
+
if api_name == "/greet":
|
| 298 |
+
result = client.predict("Agentic Space Factory", api_name=api_name)
|
| 299 |
+
else:
|
| 300 |
+
result = client.predict(api_name=api_name)
|
| 301 |
+
payload = {"status": "success", "attempt": attempt, "api_name": api_name, "discovered_api_names": discovered, "result_repr": repr(result)[:2000], "validator": "gradio_client"}
|
| 302 |
+
write_json(run_dir / "tests" / "test_result.json", payload)
|
| 303 |
+
append_event(events_path, "api_validation", "success", "Gradio API validation passed", {"attempt": attempt, "api_name": api_name, "discovered_api_names": discovered})
|
| 304 |
+
return payload
|
| 305 |
+
except Exception as exc:
|
| 306 |
+
errors.append({"api_name": api_name, "error": str(exc)[:1000]})
|
| 307 |
+
raise RuntimeError("; ".join(f"{e['api_name']}: {e['error']}" for e in errors[:5]) or "No callable API endpoints found")
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def validate_live_api(api, target_space_id: str, token: str, run_dir: Path, events_path: Path, timeout_s: int = 900):
|
| 311 |
+
append_event(events_path, "api_validation", "started", "Waiting for live HTTP /health or Gradio API to become available")
|
| 312 |
+
deadline = time.time() + timeout_s
|
| 313 |
+
attempt = 0
|
| 314 |
+
last_error = None
|
| 315 |
+
runtime_error_count = 0
|
| 316 |
+
while time.time() < deadline:
|
| 317 |
+
attempt += 1
|
| 318 |
+
runtime_payload = write_space_runtime(api, target_space_id, token, run_dir, events_path, attempt)
|
| 319 |
+
stage = str(runtime_payload.get("stage") or "").upper()
|
| 320 |
+
if "RUNTIME_ERROR" in stage:
|
| 321 |
+
runtime_error_count += 1
|
| 322 |
+
collect_space_logs(target_space_id, token, run_dir, events_path)
|
| 323 |
+
last_error = f"Space runtime stage is {stage}"
|
| 324 |
+
if runtime_error_count >= 2:
|
| 325 |
+
raise RuntimeError(f"Space is in RUNTIME_ERROR. See logs/space_logs_runtime.txt and logs/space_logs_build.txt. Last runtime: {runtime_payload}")
|
| 326 |
+
try:
|
| 327 |
+
return validate_http_health(target_space_id, token, run_dir, events_path, attempt)
|
| 328 |
+
except Exception as exc:
|
| 329 |
+
last_error = f"HTTP /health failed: {exc}"
|
| 330 |
+
try:
|
| 331 |
+
return validate_gradio_api(target_space_id, token, run_dir, events_path, attempt)
|
| 332 |
+
except Exception as exc:
|
| 333 |
+
last_error = (last_error or "") + f"; Gradio API failed: {exc}"
|
| 334 |
+
append_event(events_path, "api_validation", "waiting", "Live health/API not ready yet", {"attempt": attempt, "runtime": runtime_payload, "error": last_error[-1500:] if last_error else None})
|
| 335 |
+
time.sleep(30)
|
| 336 |
+
collect_space_logs(target_space_id, token, run_dir, events_path)
|
| 337 |
+
raise RuntimeError(f"Live health/API validation did not pass before timeout: {last_error}")
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def is_auth_or_billing_like_error(error: str | None) -> bool:
|
| 341 |
+
value = error or ""
|
| 342 |
+
markers = [
|
| 343 |
+
"401",
|
| 344 |
+
"402",
|
| 345 |
+
"403",
|
| 346 |
+
"Invalid username or password",
|
| 347 |
+
"Unauthorized",
|
| 348 |
+
"Repository Not Found",
|
| 349 |
+
"payment",
|
| 350 |
+
"billing",
|
| 351 |
+
"quota",
|
| 352 |
+
"grant",
|
| 353 |
+
]
|
| 354 |
+
return any(marker.lower() in value.lower() for marker in markers)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def request_hardware(api, target_space_id: str, hardware: str, token: str, events_path: Path, step: str, retries: int = 2):
|
| 358 |
+
"""Best-effort hardware request after Space creation.
|
| 359 |
+
|
| 360 |
+
V23 tries hardware at create_repo time first. This function remains as a
|
| 361 |
+
fallback for cases where a Space was created on CPU and the Hub later
|
| 362 |
+
accepts a hardware switch. Auth/billing/quota errors are not retried.
|
| 363 |
+
"""
|
| 364 |
+
if not hardware:
|
| 365 |
+
return {"phase": "post_create_request", "requested": False, "hardware": hardware, "ok": False, "error": "empty hardware"}
|
| 366 |
+
last_error = None
|
| 367 |
+
for attempt in range(1, retries + 1):
|
| 368 |
+
try:
|
| 369 |
+
runtime = api.request_space_hardware(repo_id=target_space_id, hardware=hardware, token=token)
|
| 370 |
+
payload = {
|
| 371 |
+
"phase": "post_create_request",
|
| 372 |
+
"requested": True,
|
| 373 |
+
"hardware": hardware,
|
| 374 |
+
"ok": True,
|
| 375 |
+
"attempt": attempt,
|
| 376 |
+
"runtime_stage": getattr(getattr(runtime, "stage", None), "value", str(getattr(runtime, "stage", None))),
|
| 377 |
+
"requested_hardware": getattr(runtime, "requested_hardware", None),
|
| 378 |
+
"hardware_current": getattr(runtime, "hardware", None),
|
| 379 |
+
}
|
| 380 |
+
append_event(events_path, step, "success", f"Requested Space hardware {hardware}", payload)
|
| 381 |
+
return payload
|
| 382 |
+
except Exception as exc:
|
| 383 |
+
last_error = str(exc)[:2000]
|
| 384 |
+
auth_like = is_auth_or_billing_like_error(last_error)
|
| 385 |
+
payload = {"phase": "post_create_request", "attempt": attempt, "hardware": hardware, "error": last_error, "manual_action_required": auth_like}
|
| 386 |
+
append_event(events_path, step, "failed" if auth_like or attempt == retries else "waiting", f"Could not request Space hardware {hardware}", payload)
|
| 387 |
+
if auth_like:
|
| 388 |
+
return {"phase": "post_create_request", "requested": True, "hardware": hardware, "ok": False, "attempts": attempt, "error": last_error, "manual_action_required": True}
|
| 389 |
+
if attempt < retries:
|
| 390 |
+
time.sleep(8 * attempt)
|
| 391 |
+
return {"phase": "post_create_request", "requested": True, "hardware": hardware, "ok": False, "attempts": retries, "error": last_error, "manual_action_required": False}
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def build_hardware_sequence(preferred_hardware: str, fallback_hardware: str, allow_fixed_gpu_fallback: bool) -> list[str]:
|
| 395 |
+
sequence = []
|
| 396 |
+
for hw in ["zero-a10g", preferred_hardware, fallback_hardware if allow_fixed_gpu_fallback else None]:
|
| 397 |
+
value = (hw or "").strip()
|
| 398 |
+
if value and value not in sequence:
|
| 399 |
+
sequence.append(value)
|
| 400 |
+
return sequence
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def create_space_with_hardware_strategy(api, target_space_id: str, token: str, preferred_hardware: str, fallback_hardware: str, allow_fixed_gpu_fallback: bool, events_path: Path):
|
| 404 |
+
"""Create a private Space and request hardware as early as possible.
|
| 405 |
+
|
| 406 |
+
HF supports `space_hardware` directly on create_repo. This is the cleanest
|
| 407 |
+
moment to request hardware because the Space does not need a second restart.
|
| 408 |
+
If OAuth/billing/quota prevents automatic hardware selection, fall back to
|
| 409 |
+
a normal private CPU Space and mark manual hardware as required.
|
| 410 |
+
"""
|
| 411 |
+
sequence = build_hardware_sequence(preferred_hardware, fallback_hardware, allow_fixed_gpu_fallback)
|
| 412 |
+
attempts = []
|
| 413 |
+
|
| 414 |
+
for hardware in sequence:
|
| 415 |
+
try:
|
| 416 |
+
append_event(events_path, "create_space_hardware", "started", f"Creating private Space with requested hardware {hardware}", {"target_space": target_space_id, "hardware": hardware})
|
| 417 |
+
api.create_repo(
|
| 418 |
+
repo_id=target_space_id,
|
| 419 |
+
repo_type="space",
|
| 420 |
+
space_sdk="gradio",
|
| 421 |
+
private=True,
|
| 422 |
+
exist_ok=False,
|
| 423 |
+
space_hardware=hardware,
|
| 424 |
+
token=token,
|
| 425 |
+
)
|
| 426 |
+
payload = {"phase": "create_repo_space_hardware", "hardware": hardware, "ok": True, "target_space": target_space_id}
|
| 427 |
+
append_event(events_path, "create_space", "success", f"Private target Space created with requested hardware {hardware}", payload)
|
| 428 |
+
return {"created": True, "selected_hardware": hardware, "requested_sequence": sequence, "attempts": attempts + [payload], "manual_action_required": False}
|
| 429 |
+
except Exception as exc:
|
| 430 |
+
error = str(exc)[:2500]
|
| 431 |
+
manual = is_auth_or_billing_like_error(error)
|
| 432 |
+
payload = {"phase": "create_repo_space_hardware", "hardware": hardware, "ok": False, "error": error, "manual_action_required": manual}
|
| 433 |
+
attempts.append(payload)
|
| 434 |
+
append_event(events_path, "create_space_hardware", "failed", f"Could not create Space with requested hardware {hardware}", payload)
|
| 435 |
+
# Continue through the sequence: ZeroGPU quota/auth can fail while a fixed GPU
|
| 436 |
+
# may still be worth trying. If fixed GPU also fails, we'll create CPU below.
|
| 437 |
+
|
| 438 |
+
append_event(events_path, "create_space", "started", "Creating private target Space on default CPU after hardware-at-creation attempts failed", {"target_space": target_space_id})
|
| 439 |
+
api.create_repo(repo_id=target_space_id, repo_type="space", space_sdk="gradio", private=True, exist_ok=False, token=token)
|
| 440 |
+
cpu_payload = {"phase": "create_repo_default_cpu", "hardware": "cpu-basic", "ok": True, "target_space": target_space_id, "manual_action_required": True}
|
| 441 |
+
append_event(events_path, "create_space", "success", "Private target Space created on default CPU; manual hardware selection may be required", cpu_payload)
|
| 442 |
+
return {"created": True, "selected_hardware": "default-cpu-or-existing", "requested_sequence": sequence, "attempts": attempts + [cpu_payload], "manual_action_required": True}
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def create_initial_workspace(workspace: Path, model_id: str, target_space_id: str, preferred_hardware: str, fallback_hardware: str, allow_fallback: bool, implementation_mode: str, model_analysis: dict | None = None):
|
| 446 |
+
workspace.mkdir(parents=True, exist_ok=True)
|
| 447 |
+
model_analysis = model_analysis or {}
|
| 448 |
+
pipeline_tag = model_analysis.get("pipeline_tag")
|
| 449 |
+
library_name = model_analysis.get("library_name")
|
| 450 |
+
tags = model_analysis.get("tags", [])[:40]
|
| 451 |
+
siblings = model_analysis.get("siblings", [])[:60]
|
| 452 |
+
app_py = f"""import gradio as gr
|
| 453 |
+
from huggingface_hub import model_info, list_repo_files
|
| 454 |
+
|
| 455 |
+
MODEL_ID = {model_id!r}
|
| 456 |
+
TARGET_SPACE_ID = {target_space_id!r}
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def health():
|
| 460 |
+
return {{
|
| 461 |
+
"status": "booted",
|
| 462 |
+
"model_id": MODEL_ID,
|
| 463 |
+
"target_space_id": TARGET_SPACE_ID,
|
| 464 |
+
"stage": "initial-scaffold",
|
| 465 |
+
"note": "Pi should replace this scaffold with a model-specific demo while preserving a cheap health endpoint.",
|
| 466 |
+
}}
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def placeholder(*args):
|
| 470 |
+
return "Initial scaffold. Pi should replace this with a model-specific inference path, or write TECHNICAL_BLOCKERS.json."
|
| 471 |
+
|
| 472 |
+
with gr.Blocks(title="Generated Model Space — Agentic Space Factory") as demo:
|
| 473 |
+
gr.Markdown("# Generated Model Space — Agentic Space Factory")
|
| 474 |
+
gr.Markdown(f"Private generated Space for `{{MODEL_ID}}`.")
|
| 475 |
+
gr.JSON(label="Health", value=health(), every=None)
|
| 476 |
+
gr.Button("Health check").click(fn=health, inputs=None, outputs=gr.JSON(), api_name="health")
|
| 477 |
+
gr.Textbox(label="Input", value="Hello from Agentic Space Factory").submit(fn=placeholder, inputs=None, outputs=gr.Textbox(), api_name="predict")
|
| 478 |
+
gr.Button("Run placeholder").click(fn=placeholder, inputs=None, outputs=gr.Textbox(), api_name="predict")
|
| 479 |
+
|
| 480 |
+
if __name__ == "__main__":
|
| 481 |
+
demo.launch()
|
| 482 |
+
"""
|
| 483 |
+
(workspace / "app.py").write_text(app_py, encoding="utf-8")
|
| 484 |
+
req = """gradio>=6.0.0
|
| 485 |
+
huggingface_hub>=0.34.0,<2.0.0
|
| 486 |
+
spaces
|
| 487 |
+
transformers>=4.45.0,<6.0.0
|
| 488 |
+
diffusers
|
| 489 |
+
accelerate
|
| 490 |
+
safetensors
|
| 491 |
+
torch
|
| 492 |
+
kernels
|
| 493 |
+
pillow
|
| 494 |
+
numpy
|
| 495 |
+
requests
|
| 496 |
+
"""
|
| 497 |
+
(workspace / "requirements.txt").write_text(req, encoding="utf-8")
|
| 498 |
+
readme = f"""---
|
| 499 |
+
title: Generated Model Space
|
| 500 |
+
sdk: gradio
|
| 501 |
+
app_file: app.py
|
| 502 |
+
python_version: "3.10"
|
| 503 |
+
suggested_hardware: {preferred_hardware or fallback_hardware or "cpu-basic"}
|
| 504 |
+
short_description: "Agent-built model demo"
|
| 505 |
+
---
|
| 506 |
+
|
| 507 |
+
# Generated Model Space — Agentic Space Factory
|
| 508 |
+
|
| 509 |
+
Private generated Space for `{model_id}`.
|
| 510 |
+
|
| 511 |
+
This Space is created by Agentic Space Factory. It should remain private until manually reviewed.
|
| 512 |
+
"""
|
| 513 |
+
(workspace / "README.md").write_text(readme, encoding="utf-8")
|
| 514 |
+
analysis_json = json.dumps({"pipeline_tag": pipeline_tag, "library_name": library_name, "tags": tags, "siblings": siblings}, indent=2, ensure_ascii=False)
|
| 515 |
+
goal = f"""You are Pi running inside a Hugging Face Job for Agentic Space Factory.
|
| 516 |
+
|
| 517 |
+
Goal: build the best possible private Hugging Face Space demo for an arbitrary model card.
|
| 518 |
+
|
| 519 |
+
MODEL_ID: {model_id}
|
| 520 |
+
TARGET_SPACE_ID: {target_space_id}
|
| 521 |
+
IMPLEMENTATION_MODE: {implementation_mode}
|
| 522 |
+
MODEL_METADATA:
|
| 523 |
+
```json
|
| 524 |
+
{analysis_json}
|
| 525 |
+
```
|
| 526 |
+
|
| 527 |
+
First read and follow the operational rules from this gist:
|
| 528 |
+
{GIST_URL}
|
| 529 |
+
|
| 530 |
+
Non-negotiable safety and product constraints:
|
| 531 |
+
- The target Space must remain private.
|
| 532 |
+
- Do not delete any user resources.
|
| 533 |
+
- Do not print secrets or tokens.
|
| 534 |
+
- Work only inside the current workspace.
|
| 535 |
+
- The wrapper will create the private Space, request hardware best-effort, upload files, and validate the live app. Do not create/delete repos yourself in this builder worker.
|
| 536 |
+
- Preserve a cheap health endpoint named `health` with `api_name="health"`. It must not load weights, run GPU work, or download large files.
|
| 537 |
+
- Do not pin huggingface_hub below 1.0. Use huggingface_hub>=0.34.0,<2.0.0 unless the model card requires a narrower compatible range. If transformers>=5 is used, keep huggingface_hub compatible with it, for example huggingface_hub>=1.5.0,<2.0.0.
|
| 538 |
+
- README.md frontmatter must remain valid; if it uses short_description, it must be 60 characters or fewer.
|
| 539 |
+
|
| 540 |
+
Implementation contract:
|
| 541 |
+
- If IMPLEMENTATION_MODE is `full-inference-gated`, you are not allowed to silently replace generation with a placeholder and call it success.
|
| 542 |
+
- Try to implement the closest real inference path for the model card using evidence from README, model metadata, config files, and repo files.
|
| 543 |
+
- You may choose an appropriate Gradio UI for the task: text, image, audio, video, multimodal, embeddings, classification, etc.
|
| 544 |
+
- If the model is standard and feasible, implement a real generate/predict function and expose it as a Gradio endpoint.
|
| 545 |
+
- If the model requires GPU, add ZeroGPU-compatible `@spaces.GPU(...)` only around the inference function. Do not decorate health.
|
| 546 |
+
- If the model requires special dependencies, include them only when needed and document risks.
|
| 547 |
+
- Investigate compatibility fallbacks before declaring a blocker: PyTorch SDPA, xformers, HF Kernels where relevant, CPU/offload/lazy loading, smaller resolution/steps, safe smoke-test inputs.
|
| 548 |
+
- If real inference is impossible or unsafe in a Space, write TECHNICAL_BLOCKERS.json with concrete evidence for every blocker.
|
| 549 |
+
|
| 550 |
+
Deliverables:
|
| 551 |
+
- app.py must boot on Hugging Face Spaces.
|
| 552 |
+
- app.py must expose health/api_name="health".
|
| 553 |
+
- If real generation is implemented, generate/predict must attempt a real model call, not only return a textual diagnostic.
|
| 554 |
+
- If real generation is not implemented, write TECHNICAL_BLOCKERS.json with: full_inference_implemented=false, blockers[], evidence[], minimum_runtime, and suggested_next_step.
|
| 555 |
+
- Write INFERENCE_CONTRACT.json with: full_inference_implemented, health_endpoint, primary_api_name, expected_output_type, validation_level, requires_gpu, estimated_vram, and blockers_count.
|
| 556 |
+
- README.md must explain the runtime strategy, task, limitations, and how to test.
|
| 557 |
+
- Write a concise PI_SUMMARY.md with what you changed and whether full inference is implemented.
|
| 558 |
+
"""
|
| 559 |
+
(workspace / "GOAL.md").write_text(goal, encoding="utf-8")
|
| 560 |
+
return ["app.py", "requirements.txt", "README.md", "GOAL.md"]
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def sanitize_readme_metadata(workspace: Path, events_path: Path):
|
| 564 |
+
readme_path = workspace / "README.md"
|
| 565 |
+
if not readme_path.exists():
|
| 566 |
+
return
|
| 567 |
+
text = readme_path.read_text(encoding="utf-8", errors="ignore")
|
| 568 |
+
if not text.startswith("---"):
|
| 569 |
+
return
|
| 570 |
+
parts = text.split("---", 2)
|
| 571 |
+
if len(parts) < 3:
|
| 572 |
+
return
|
| 573 |
+
_, frontmatter, body = parts
|
| 574 |
+
changed = False
|
| 575 |
+
sanitized_lines = []
|
| 576 |
+
for line in frontmatter.splitlines():
|
| 577 |
+
if line.strip().startswith("short_description:"):
|
| 578 |
+
value = "Generated model demo"
|
| 579 |
+
sanitized_lines.append(f"short_description: {value}")
|
| 580 |
+
changed = True
|
| 581 |
+
else:
|
| 582 |
+
sanitized_lines.append(line)
|
| 583 |
+
# If Pi added other unexpectedly long one-line metadata values, leave them alone:
|
| 584 |
+
# the known Hub validation blocker for this run was short_description > 60 chars.
|
| 585 |
+
if changed:
|
| 586 |
+
new_text = "---\n" + "\n".join(sanitized_lines).strip() + "\n---" + body
|
| 587 |
+
readme_path.write_text(new_text, encoding="utf-8")
|
| 588 |
+
append_event(events_path, "metadata_sanitize", "success", "Sanitized README metadata", {"short_description": "Generated model demo"})
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def normalize_requirements_for_modern_hub(workspace: Path, events_path: Path):
|
| 593 |
+
"""Prevent a known resolver conflict in generated Spaces.
|
| 594 |
+
|
| 595 |
+
Older builder versions forced `huggingface_hub<1.0.0` to avoid old Gradio
|
| 596 |
+
import issues. Modern Spaces can use Gradio 6 and recent Transformers;
|
| 597 |
+
Transformers 5.x requires huggingface-hub >=1.5.0, so the old pin breaks
|
| 598 |
+
builds. Keep the constraint broad and modern unless Pi intentionally uses a
|
| 599 |
+
different compatible stack.
|
| 600 |
+
"""
|
| 601 |
+
req_path = workspace / "requirements.txt"
|
| 602 |
+
if not req_path.exists():
|
| 603 |
+
return
|
| 604 |
+
raw = req_path.read_text(encoding="utf-8", errors="ignore")
|
| 605 |
+
lines = [line.rstrip() for line in raw.splitlines()]
|
| 606 |
+
changed = False
|
| 607 |
+
filtered = []
|
| 608 |
+
transformers_needs_hub_15 = False
|
| 609 |
+
for line in lines:
|
| 610 |
+
stripped = line.strip()
|
| 611 |
+
low = stripped.lower().replace("_", "-")
|
| 612 |
+
if low.startswith("huggingface-hub"):
|
| 613 |
+
if "<1" in low or "< 1" in low or ",<1" in low:
|
| 614 |
+
changed = True
|
| 615 |
+
# Always replace with the policy line to avoid duplicate/conflicting pins.
|
| 616 |
+
changed = True
|
| 617 |
+
continue
|
| 618 |
+
if low.startswith("transformers") and (">=5" in low or "==5" in low or "~=5" in low):
|
| 619 |
+
transformers_needs_hub_15 = True
|
| 620 |
+
filtered.append(line)
|
| 621 |
+
hub_line = "huggingface_hub>=1.5.0,<2.0.0" if transformers_needs_hub_15 else "huggingface_hub>=0.34.0,<2.0.0"
|
| 622 |
+
# Put hub near the top, after any --extra-index-url lines.
|
| 623 |
+
insert_at = 0
|
| 624 |
+
while insert_at < len(filtered) and filtered[insert_at].strip().startswith("--"):
|
| 625 |
+
insert_at += 1
|
| 626 |
+
filtered.insert(insert_at, hub_line)
|
| 627 |
+
new = "\n".join(line for line in filtered if line.strip()) + "\n"
|
| 628 |
+
if new != raw:
|
| 629 |
+
req_path.write_text(new, encoding="utf-8")
|
| 630 |
+
append_event(events_path, "requirements_sanitize", "success", "Normalized huggingface_hub requirement for modern dependency resolution", {"huggingface_hub": hub_line})
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
def repair_workspace_with_pi(workspace: Path, run_dir: Path, events_path: Path, pi_model: str, target_space_id: str, model_id: str, failure_reason: str):
|
| 634 |
+
"""Ask Pi for one minimal build/runtime repair pass based on collected logs."""
|
| 635 |
+
logs_dir = run_dir / "logs"
|
| 636 |
+
build_log = (logs_dir / "space_logs_build.txt").read_text(encoding="utf-8", errors="ignore") if (logs_dir / "space_logs_build.txt").exists() else ""
|
| 637 |
+
runtime_log = (logs_dir / "space_logs_runtime.txt").read_text(encoding="utf-8", errors="ignore") if (logs_dir / "space_logs_runtime.txt").exists() else ""
|
| 638 |
+
repair_dir = run_dir / "repair"
|
| 639 |
+
before_dir = repair_dir / "before"
|
| 640 |
+
after_dir = repair_dir / "after"
|
| 641 |
+
if before_dir.exists():
|
| 642 |
+
shutil.rmtree(before_dir)
|
| 643 |
+
shutil.copytree(workspace, before_dir, ignore=shutil.ignore_patterns(".git", "node_modules", "__pycache__", "*.pyc"))
|
| 644 |
+
goal = f"""You are Pi repairing a Hugging Face Space generated by Agentic Space Factory.
|
| 645 |
+
|
| 646 |
+
MODEL_ID: {model_id}
|
| 647 |
+
TARGET_SPACE_ID: {target_space_id}
|
| 648 |
+
|
| 649 |
+
The first build/runtime validation failed.
|
| 650 |
+
|
| 651 |
+
Failure summary:
|
| 652 |
+
{failure_reason[:4000]}
|
| 653 |
+
|
| 654 |
+
Build log tail:
|
| 655 |
+
```text
|
| 656 |
+
{build_log[-12000:]}
|
| 657 |
+
```
|
| 658 |
+
|
| 659 |
+
Runtime log tail:
|
| 660 |
+
```text
|
| 661 |
+
{runtime_log[-12000:]}
|
| 662 |
+
```
|
| 663 |
+
|
| 664 |
+
Repair contract:
|
| 665 |
+
- Make the smallest patch possible.
|
| 666 |
+
- Prefer fixing dependency resolver conflicts, missing imports, invalid metadata, Gradio endpoint bugs, and import-order issues.
|
| 667 |
+
- Do not replace real inference with a placeholder unless TECHNICAL_BLOCKERS.json clearly explains why full inference is impossible.
|
| 668 |
+
- Preserve a cheap health endpoint with api_name="health".
|
| 669 |
+
- Keep README frontmatter valid, short_description <= 60 chars.
|
| 670 |
+
- Do not pin huggingface_hub below 1.0. For modern generated Spaces use huggingface_hub>=0.34.0,<2.0.0. If transformers>=5 is present, use huggingface_hub>=1.5.0,<2.0.0.
|
| 671 |
+
- Do not delete the app. Do not publish anything. Work only in the current workspace.
|
| 672 |
+
|
| 673 |
+
Deliverables:
|
| 674 |
+
- patched app.py / requirements.txt / README.md as needed
|
| 675 |
+
- REPAIR_SUMMARY.md explaining the patch
|
| 676 |
+
- keep or update INFERENCE_CONTRACT.json if the inference contract changed
|
| 677 |
+
"""
|
| 678 |
+
(workspace / "REPAIR_GOAL.md").write_text(goal, encoding="utf-8")
|
| 679 |
+
append_event(events_path, "repair", "started", "Running Pi repair pass using build/runtime logs", {"model": pi_model})
|
| 680 |
+
code, out = run_cmd(["pi", "-p", goal], cwd=workspace, timeout=1500)
|
| 681 |
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
| 682 |
+
(logs_dir / "pi_repair_output.txt").write_text(out, encoding="utf-8")
|
| 683 |
+
if code != 0:
|
| 684 |
+
append_event(events_path, "repair", "failed", "Pi repair returned a non-zero exit code", {"returncode": code, "output_tail": out[-3000:]})
|
| 685 |
+
return False
|
| 686 |
+
normalize_requirements_for_modern_hub(workspace, events_path)
|
| 687 |
+
if after_dir.exists():
|
| 688 |
+
shutil.rmtree(after_dir)
|
| 689 |
+
shutil.copytree(workspace, after_dir, ignore=shutil.ignore_patterns(".git", "node_modules", "__pycache__", "*.pyc"))
|
| 690 |
+
append_event(events_path, "repair", "success", "Pi repair pass completed", {"output_tail": out[-3000:]})
|
| 691 |
+
return True
|
| 692 |
+
|
| 693 |
+
def upload_workspace(api, workspace: Path, target_space_id: str, token: str, run_dir: Path, events_path: Path):
|
| 694 |
+
sanitize_readme_metadata(workspace, events_path)
|
| 695 |
+
normalize_requirements_for_modern_hub(workspace, events_path)
|
| 696 |
+
append_event(events_path, "upload_files", "started", "Uploading generated universal model-card workspace recursively")
|
| 697 |
+
gen_dir = run_dir / "generated"
|
| 698 |
+
if gen_dir.exists():
|
| 699 |
+
shutil.rmtree(gen_dir)
|
| 700 |
+
shutil.copytree(workspace, gen_dir, ignore=shutil.ignore_patterns(".git", "node_modules", "__pycache__", "*.pyc"))
|
| 701 |
+
for filename in ["app.py", "README.md", "requirements.txt"]:
|
| 702 |
+
if not (workspace / filename).exists():
|
| 703 |
+
raise RuntimeError(f"Missing required generated file: {filename}")
|
| 704 |
+
api.upload_folder(
|
| 705 |
+
folder_path=str(workspace),
|
| 706 |
+
repo_id=target_space_id,
|
| 707 |
+
repo_type="space",
|
| 708 |
+
token=token,
|
| 709 |
+
ignore_patterns=[".git/*", "node_modules/*", "__pycache__/*", "*.pyc", "GOAL.md"],
|
| 710 |
+
)
|
| 711 |
+
uploaded_files = sorted(str(p.relative_to(workspace)) for p in workspace.rglob("*") if p.is_file() and "node_modules" not in p.parts and "__pycache__" not in p.parts)
|
| 712 |
+
append_event(events_path, "upload_files", "success", "Uploaded generated workspace folder", {"file_count": len(uploaded_files), "files_sample": uploaded_files[:50]})
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def load_json_if_exists(path: Path) -> dict:
|
| 716 |
+
if not path.exists():
|
| 717 |
+
return {}
|
| 718 |
+
try:
|
| 719 |
+
return json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
| 720 |
+
except Exception as exc:
|
| 721 |
+
return {"parse_error": str(exc), "raw_tail": path.read_text(encoding="utf-8", errors="replace")[-2000:]}
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def infer_generation_gate(workspace: Path, implementation_mode: str, validation: dict, run_dir: Path, events_path: Path) -> dict:
|
| 725 |
+
"""Classify the run separately from process success.
|
| 726 |
+
|
| 727 |
+
/health passing means the Space boots. It does not mean the generated Space
|
| 728 |
+
performs model inference. In full-inference-gated mode we require either
|
| 729 |
+
an actual implementation signal or a machine-readable blocker report.
|
| 730 |
+
"""
|
| 731 |
+
app_text = (workspace / "app.py").read_text(encoding="utf-8", errors="ignore") if (workspace / "app.py").exists() else ""
|
| 732 |
+
summary_text = (workspace / "PI_SUMMARY.md").read_text(encoding="utf-8", errors="ignore") if (workspace / "PI_SUMMARY.md").exists() else ""
|
| 733 |
+
req_text = (workspace / "requirements.txt").read_text(encoding="utf-8", errors="ignore") if (workspace / "requirements.txt").exists() else ""
|
| 734 |
+
blockers_path = workspace / "TECHNICAL_BLOCKERS.json"
|
| 735 |
+
blockers = load_json_if_exists(blockers_path)
|
| 736 |
+
|
| 737 |
+
combined = (app_text + "\n" + summary_text).lower()
|
| 738 |
+
blocked_markers = [
|
| 739 |
+
"full generation is not implemented",
|
| 740 |
+
"full generation is intentionally not wired",
|
| 741 |
+
"full inference is blocked",
|
| 742 |
+
"returns a detailed diagnostic",
|
| 743 |
+
"diagnostic report instead",
|
| 744 |
+
"placeholder generator",
|
| 745 |
+
"placeholder generation",
|
| 746 |
+
"info-only",
|
| 747 |
+
"not implemented",
|
| 748 |
+
"cannot run in this environment",
|
| 749 |
+
"out of scope",
|
| 750 |
+
]
|
| 751 |
+
blocker_detected = bool(blockers) or any(m in combined for m in blocked_markers)
|
| 752 |
+
implementation_signals = {
|
| 753 |
+
"has_spaces_gpu": "@spaces.GPU" in app_text,
|
| 754 |
+
"has_torch": "torch" in req_text or "import torch" in app_text,
|
| 755 |
+
"has_diffusers": "diffusers" in req_text or "diffusers" in app_text,
|
| 756 |
+
"has_video_output_hint": any(x in app_text.lower() for x in ["gr.video", "video", ".mp4", "ffmpeg"]),
|
| 757 |
+
"health_passed": validation.get("method") in {"http_health", "gradio"},
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
if blocker_detected:
|
| 761 |
+
status = "technical_blocker"
|
| 762 |
+
message = "Space boots, but full model inference was not implemented. See TECHNICAL_BLOCKERS.json / PI_SUMMARY.md."
|
| 763 |
+
elif implementation_mode in {"full-inference-gated", "full-inference-attempt"}:
|
| 764 |
+
# Without a video smoke test, do not claim real inference success.
|
| 765 |
+
status = "full_inference_candidate_health_passed"
|
| 766 |
+
message = "Space boots and contains inference signals, but no generation smoke test has validated a real video output."
|
| 767 |
+
else:
|
| 768 |
+
status = "health_only"
|
| 769 |
+
message = "Safe scaffold health validation passed. Full inference was not requested."
|
| 770 |
+
|
| 771 |
+
if blocker_detected and not blockers:
|
| 772 |
+
blockers = {
|
| 773 |
+
"full_inference_implemented": False,
|
| 774 |
+
"source": "worker_heuristic_from_PI_SUMMARY_or_app.py",
|
| 775 |
+
"blockers": [
|
| 776 |
+
{
|
| 777 |
+
"type": "agent_declared_or_detected_blocker",
|
| 778 |
+
"claim": "Pi-generated artifacts state that full inference is blocked/not implemented or generation returns diagnostics/placeholders.",
|
| 779 |
+
"evidence": "See PI_SUMMARY.md and app.py in generated artifacts.",
|
| 780 |
+
"severity": "blocking",
|
| 781 |
+
}
|
| 782 |
+
],
|
| 783 |
+
"required_investigations_for_next_run": [
|
| 784 |
+
"Check whether PyTorch SDPA can replace flash-attn calls.",
|
| 785 |
+
"Check whether HF Kernels flash-attn2/3/4 can replace required flash-attn APIs.",
|
| 786 |
+
"Verify whether 2-GPU context parallelism is strictly required or can be reduced to a single-GPU smoke test.",
|
| 787 |
+
],
|
| 788 |
+
}
|
| 789 |
+
(workspace / "TECHNICAL_BLOCKERS.json").write_text(json.dumps(blockers, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 790 |
+
(run_dir / "generated" / "TECHNICAL_BLOCKERS.json").write_text(json.dumps(blockers, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 791 |
+
|
| 792 |
+
gate = {
|
| 793 |
+
"status": status,
|
| 794 |
+
"message": message,
|
| 795 |
+
"implementation_mode": implementation_mode,
|
| 796 |
+
"blocker_detected": blocker_detected,
|
| 797 |
+
"implementation_signals": implementation_signals,
|
| 798 |
+
"validation_method": validation.get("method"),
|
| 799 |
+
"blockers": blockers,
|
| 800 |
+
}
|
| 801 |
+
write_json(run_dir / "inference_gate.json", gate)
|
| 802 |
+
append_event(events_path, "inference_gate", status, message, gate)
|
| 803 |
+
return gate
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
def main():
|
| 807 |
+
run_id = os.environ["RUN_ID"]
|
| 808 |
+
hf_username = os.environ.get("HF_USERNAME", "unknown")
|
| 809 |
+
bucket_source = os.environ.get("BUCKET_SOURCE", "unknown")
|
| 810 |
+
output_root = Path(os.environ.get("OUTPUT_ROOT", "/output"))
|
| 811 |
+
target_space_id = os.environ.get("TARGET_SPACE_ID", "")
|
| 812 |
+
model_id = sanitize_model_id(os.environ.get("MODEL_ID", DEFAULT_MODEL_ID))
|
| 813 |
+
pi_model = os.environ.get("PI_MODEL", "Qwen/Qwen3-Coder-Next")
|
| 814 |
+
preferred_hardware = os.environ.get("PREFERRED_SPACE_HARDWARE", "zero-a10g")
|
| 815 |
+
fallback_hardware = os.environ.get("FALLBACK_SPACE_HARDWARE", "l40sx1")
|
| 816 |
+
allow_fixed_gpu_fallback = os.environ.get("ALLOW_FIXED_GPU_FALLBACK", "true").lower() in {"1", "true", "yes", "on"}
|
| 817 |
+
implementation_mode = os.environ.get("IMPLEMENTATION_MODE", "full-inference-attempt")
|
| 818 |
+
token = os.environ.get("HF_TOKEN")
|
| 819 |
+
|
| 820 |
+
run_dir = output_root / "runs" / run_id
|
| 821 |
+
events_path = run_dir / "events.jsonl"
|
| 822 |
+
state_path = run_dir / "state.json"
|
| 823 |
+
workspace = Path("/tmp/universal_workspace")
|
| 824 |
+
|
| 825 |
+
append_event(events_path, "bootstrap", "started", "Universal model-card builder worker started", {"model_id": model_id, "target_space_id": target_space_id})
|
| 826 |
+
write_json(state_path, {"run_id": run_id, "kind": "universal_model_card_builder", "status": "running", "message": "Attempting Universal model-card builderd Space creation", "model_id": model_id, "target_space": target_space_id, "created_by": hf_username, "bucket_source": bucket_source, "created_at": now(), "updated_at": now()})
|
| 827 |
+
if not token:
|
| 828 |
+
fail(run_dir, events_path, "HF_TOKEN is missing from Job secrets")
|
| 829 |
+
if not TARGET_RE.match(target_space_id):
|
| 830 |
+
fail(run_dir, events_path, "Invalid TARGET_SPACE_ID", {"target_space_id": target_space_id})
|
| 831 |
+
|
| 832 |
+
try:
|
| 833 |
+
install_python_deps(events_path)
|
| 834 |
+
from huggingface_hub import HfApi
|
| 835 |
+
api = HfApi(token=token)
|
| 836 |
+
whoami = api.whoami(token=token)
|
| 837 |
+
append_event(events_path, "auth", "success", "Authenticated inside Job", {"whoami_name": whoami.get("name")})
|
| 838 |
+
|
| 839 |
+
append_event(events_path, "model_analysis", "started", "Fetching model metadata", {"model_id": model_id})
|
| 840 |
+
info = api.model_info(model_id, token=token, files_metadata=True)
|
| 841 |
+
siblings = [getattr(s, "rfilename", "") for s in (info.siblings or [])]
|
| 842 |
+
analysis = {"model_id": model_id, "pipeline_tag": getattr(info, "pipeline_tag", None), "library_name": getattr(info, "library_name", None), "tags": list(getattr(info, "tags", []) or [])[:100], "siblings": siblings[:160], "default_model_target": model_id == DEFAULT_MODEL_ID, "preferred_hardware": preferred_hardware, "fallback_hardware": fallback_hardware, "allow_fixed_gpu_fallback": allow_fixed_gpu_fallback, "implementation_mode": implementation_mode}
|
| 843 |
+
write_json(run_dir / "model_analysis.json", analysis)
|
| 844 |
+
append_event(events_path, "model_analysis", "success", "Model metadata fetched", {"pipeline_tag": analysis["pipeline_tag"], "library_name": analysis["library_name"]})
|
| 845 |
+
|
| 846 |
+
create_initial_workspace(workspace, model_id, target_space_id, preferred_hardware, fallback_hardware, allow_fixed_gpu_fallback, implementation_mode, analysis)
|
| 847 |
+
append_event(events_path, "workspace", "success", "Prepared universal model-card workspace", {"files": sorted(p.name for p in workspace.iterdir())})
|
| 848 |
+
|
| 849 |
+
install_pi(events_path)
|
| 850 |
+
configure_pi(events_path, pi_model)
|
| 851 |
+
append_event(events_path, "pi_run", "started", "Running Pi on universal model-card workspace", {"model": pi_model})
|
| 852 |
+
code, pi_out = run_cmd(["pi", "-p", (workspace / "GOAL.md").read_text(encoding="utf-8")], cwd=workspace, timeout=2400)
|
| 853 |
+
(run_dir / "logs").mkdir(parents=True, exist_ok=True)
|
| 854 |
+
(run_dir / "logs" / "pi_output.txt").write_text(pi_out, encoding="utf-8")
|
| 855 |
+
if code != 0:
|
| 856 |
+
append_event(events_path, "pi_run", "failed", "Pi returned a non-zero exit code", {"returncode": code, "output_tail": pi_out[-4000:]})
|
| 857 |
+
collect_pi_traces(run_dir, events_path)
|
| 858 |
+
fail(run_dir, events_path, "Pi failed before Space upload", {"returncode": code, "output_tail": pi_out[-4000:]})
|
| 859 |
+
append_event(events_path, "pi_run", "success", "Pi completed universal model-card workspace pass", {"output_tail": pi_out[-2000:]})
|
| 860 |
+
if not (workspace / "PI_SUMMARY.md").exists():
|
| 861 |
+
(workspace / "PI_SUMMARY.md").write_text("# Pi Summary\n\nPi did not create a PI_SUMMARY.md. See logs/pi_output.txt.\n", encoding="utf-8")
|
| 862 |
+
|
| 863 |
+
app_text = (workspace / "app.py").read_text(encoding="utf-8", errors="ignore")
|
| 864 |
+
if "/health" not in app_text and "api_name=\"health\"" not in app_text and "api_name='health'" not in app_text:
|
| 865 |
+
append_event(events_path, "pi_verification", "failed", "app.py does not appear to expose /health; injecting safe health endpoint is not implemented")
|
| 866 |
+
fail(run_dir, events_path, "Pi output did not preserve a /health endpoint")
|
| 867 |
+
append_event(events_path, "pi_verification", "success", "Pi output preserved health validation endpoint")
|
| 868 |
+
|
| 869 |
+
append_event(events_path, "hardware_strategy", "started", "Creating Space with hardware-at-creation strategy", {"preferred_hardware": preferred_hardware, "fallback_hardware": fallback_hardware, "allow_fixed_gpu_fallback": allow_fixed_gpu_fallback})
|
| 870 |
+
hardware_strategy = create_space_with_hardware_strategy(
|
| 871 |
+
api,
|
| 872 |
+
target_space_id,
|
| 873 |
+
token,
|
| 874 |
+
preferred_hardware,
|
| 875 |
+
fallback_hardware,
|
| 876 |
+
allow_fixed_gpu_fallback,
|
| 877 |
+
events_path,
|
| 878 |
+
)
|
| 879 |
+
selected_hardware = hardware_strategy.get("selected_hardware") or "default-cpu-or-existing"
|
| 880 |
+
hardware_attempts = list(hardware_strategy.get("attempts") or [])
|
| 881 |
+
requested_hardware_sequence = list(hardware_strategy.get("requested_sequence") or [])
|
| 882 |
+
|
| 883 |
+
# Upload after create. If create_repo(space_hardware=...) succeeded, the build
|
| 884 |
+
# starts directly on the requested hardware. If it fell back to CPU, the run
|
| 885 |
+
# remains valid but will be marked manual_hardware_required when inference
|
| 886 |
+
# signals indicate GPU is needed.
|
| 887 |
+
upload_workspace(api, workspace, target_space_id, token, run_dir, events_path)
|
| 888 |
+
|
| 889 |
+
if selected_hardware == "default-cpu-or-existing":
|
| 890 |
+
append_event(events_path, "hardware", "warning", "Automatic hardware-at-creation failed; Space is on default CPU unless user changes it manually", {"attempts": hardware_attempts})
|
| 891 |
+
|
| 892 |
+
write_json(run_dir / "hardware_attempts.json", {"selected_hardware": selected_hardware, "requested_sequence": requested_hardware_sequence, "attempts": hardware_attempts, "strategy": "create_repo_space_hardware_first"})
|
| 893 |
+
write_json(run_dir / "hardware_strategy.json", {"selected_hardware": selected_hardware, "requested_sequence": requested_hardware_sequence, "attempts": hardware_attempts, "manual_action_required": selected_hardware == "default-cpu-or-existing", "strategy": "create_repo_space_hardware_first"})
|
| 894 |
+
|
| 895 |
+
try:
|
| 896 |
+
validation = validate_live_api(api, target_space_id, token, run_dir, events_path, timeout_s=1200)
|
| 897 |
+
except Exception as validation_error:
|
| 898 |
+
append_event(events_path, "repair", "started", "Initial live validation failed; attempting one repair pass", {"error": str(validation_error)[:2000]})
|
| 899 |
+
collect_space_logs(target_space_id, token, run_dir, events_path)
|
| 900 |
+
repaired = repair_workspace_with_pi(workspace, run_dir, events_path, pi_model, target_space_id, model_id, str(validation_error))
|
| 901 |
+
if not repaired:
|
| 902 |
+
raise
|
| 903 |
+
upload_workspace(api, workspace, target_space_id, token, run_dir, events_path)
|
| 904 |
+
validation = validate_live_api(api, target_space_id, token, run_dir, events_path, timeout_s=1200)
|
| 905 |
+
inference_gate = infer_generation_gate(workspace, implementation_mode, validation, run_dir, events_path)
|
| 906 |
+
|
| 907 |
+
# If the generated app looks like real GPU inference but automatic
|
| 908 |
+
# hardware requests failed, classify the run honestly as needing manual
|
| 909 |
+
# hardware instead of pretending CPU/default hardware is enough. the existing-Space validation workflow
|
| 910 |
+
# can then smoke-test generation after the user sets a GPU manually.
|
| 911 |
+
manual_hw_required = selected_hardware == "default-cpu-or-existing" and inference_gate.get("status") not in {"technical_blocker", "health_only"} and (
|
| 912 |
+
inference_gate.get("implementation_signals", {}).get("has_spaces_gpu")
|
| 913 |
+
or inference_gate.get("implementation_signals", {}).get("has_torch")
|
| 914 |
+
or any((a.get("manual_action_required") for a in hardware_attempts if isinstance(a, dict)))
|
| 915 |
+
)
|
| 916 |
+
if manual_hw_required:
|
| 917 |
+
inference_gate = dict(inference_gate)
|
| 918 |
+
inference_gate["status"] = "manual_hardware_required"
|
| 919 |
+
inference_gate["message"] = "Space was generated and boots, but automatic ZeroGPU/fixed-GPU assignment failed. Set hardware manually, then run the existing-Space validation workflow."
|
| 920 |
+
inference_gate["manual_hardware_required"] = True
|
| 921 |
+
inference_gate["hardware_attempts"] = hardware_attempts
|
| 922 |
+
write_json(run_dir / "inference_gate.json", inference_gate)
|
| 923 |
+
append_event(events_path, "inference_gate", "manual_hardware_required", inference_gate["message"], inference_gate)
|
| 924 |
+
|
| 925 |
+
collect_pi_traces(run_dir, events_path)
|
| 926 |
+
|
| 927 |
+
final_state = {
|
| 928 |
+
"run_id": run_id,
|
| 929 |
+
"kind": "universal_model_card_builder",
|
| 930 |
+
"status": inference_gate["status"],
|
| 931 |
+
"message": inference_gate["message"],
|
| 932 |
+
"model_id": model_id,
|
| 933 |
+
"target_space": target_space_id,
|
| 934 |
+
"target_space_url": f"https://huggingface.co/spaces/{target_space_id}",
|
| 935 |
+
"selected_hardware": selected_hardware,
|
| 936 |
+
"hardware_attempts": hardware_attempts,
|
| 937 |
+
"validation": validation,
|
| 938 |
+
"inference_gate": inference_gate,
|
| 939 |
+
"updated_at": now(),
|
| 940 |
+
"created_by": hf_username,
|
| 941 |
+
"bucket_source": bucket_source,
|
| 942 |
+
}
|
| 943 |
+
write_json(state_path, final_state)
|
| 944 |
+
report = f"""# Agentic Space Factory — Universal Model-Card Builder Report
|
| 945 |
+
|
| 946 |
+
Run ID: `{run_id}`
|
| 947 |
+
|
| 948 |
+
Status: **{inference_gate['status']}**
|
| 949 |
+
|
| 950 |
+
{inference_gate['message']}
|
| 951 |
+
|
| 952 |
+
Target Space: https://huggingface.co/spaces/{target_space_id}
|
| 953 |
+
|
| 954 |
+
Model: `{model_id}`
|
| 955 |
+
|
| 956 |
+
## Hardware
|
| 957 |
+
|
| 958 |
+
Selected/requested hardware: `{selected_hardware}`
|
| 959 |
+
|
| 960 |
+
Hardware changes are best-effort with OAuth. If requests fail with 401/auth/billing errors, set the Space hardware manually and rerun validation.
|
| 961 |
+
|
| 962 |
+
```json
|
| 963 |
+
{json.dumps(hardware_attempts, indent=2, ensure_ascii=False)}
|
| 964 |
+
```
|
| 965 |
+
|
| 966 |
+
## Health validation
|
| 967 |
+
|
| 968 |
+
The wrapper validated the live Space using HTTP `/health` first, with Gradio Client as fallback. This only proves bootability.
|
| 969 |
+
|
| 970 |
+
```json
|
| 971 |
+
{json.dumps(validation, indent=2, ensure_ascii=False)}
|
| 972 |
+
```
|
| 973 |
+
|
| 974 |
+
## Full-inference gate
|
| 975 |
+
|
| 976 |
+
```json
|
| 977 |
+
{json.dumps(inference_gate, indent=2, ensure_ascii=False)}
|
| 978 |
+
```
|
| 979 |
+
|
| 980 |
+
## Pi summary
|
| 981 |
+
|
| 982 |
+
{(workspace / 'PI_SUMMARY.md').read_text(encoding='utf-8', errors='ignore') if (workspace / 'PI_SUMMARY.md').exists() else 'No PI_SUMMARY.md was produced.'}
|
| 983 |
+
|
| 984 |
+
## Safety
|
| 985 |
+
|
| 986 |
+
- The target Space was created private.
|
| 987 |
+
- No public publication was attempted.
|
| 988 |
+
- Raw traces should remain private; redacted traces are stored separately.
|
| 989 |
+
- If fallback fixed GPU was used or selected manually, review billing/hardware settings manually after the run.
|
| 990 |
+
"""
|
| 991 |
+
(run_dir / "report.md").write_text(report, encoding="utf-8")
|
| 992 |
+
append_event(events_path, "report_write", "success", "Wrote report.md")
|
| 993 |
+
append_event(events_path, "done", inference_gate["status"], "Universal model-card builder completed", {"target_space": target_space_id, "selected_hardware": selected_hardware, "gate_status": inference_gate["status"]})
|
| 994 |
+
except SystemExit:
|
| 995 |
+
raise
|
| 996 |
+
except Exception as exc:
|
| 997 |
+
try:
|
| 998 |
+
collect_pi_traces(run_dir, events_path)
|
| 999 |
+
except Exception:
|
| 1000 |
+
pass
|
| 1001 |
+
fail(run_dir, events_path, "Universal model-card builder worker failed", {"error": str(exc)})
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
if __name__ == "__main__":
|
| 1005 |
+
main()
|
| 1006 |
+
|
| 1007 |
+
'''
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
VALIDATE_EXISTING_SPACE_WORKER_SCRIPT = r'''
|
| 1011 |
+
import json
|
| 1012 |
+
import os
|
| 1013 |
+
import re
|
| 1014 |
+
import shutil
|
| 1015 |
+
import subprocess
|
| 1016 |
+
import sys
|
| 1017 |
+
import time
|
| 1018 |
+
from datetime import datetime, timezone
|
| 1019 |
+
from pathlib import Path
|
| 1020 |
+
|
| 1021 |
+
TARGET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,95}/[A-Za-z0-9][A-Za-z0-9._-]{1,95}$")
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
def now():
|
| 1025 |
+
return datetime.now(timezone.utc).isoformat()
|
| 1026 |
+
|
| 1027 |
+
|
| 1028 |
+
def write_json(path: Path, payload: dict):
|
| 1029 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 1030 |
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
def append_event(path: Path, step: str, status: str, message: str, data: dict | None = None):
|
| 1034 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 1035 |
+
event = {"ts": now(), "step": step, "status": status, "message": message, "data": data or {}}
|
| 1036 |
+
line = json.dumps(event, ensure_ascii=False)
|
| 1037 |
+
with path.open("a", encoding="utf-8") as f:
|
| 1038 |
+
f.write(line + "\n")
|
| 1039 |
+
print(line, flush=True)
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def redact_text(text: str | None) -> str:
|
| 1043 |
+
if not text:
|
| 1044 |
+
return ""
|
| 1045 |
+
value = text
|
| 1046 |
+
for secret_name in ["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"]:
|
| 1047 |
+
secret = os.environ.get(secret_name)
|
| 1048 |
+
if secret:
|
| 1049 |
+
value = value.replace(secret, "[REDACTED]")
|
| 1050 |
+
value = re.sub(r"Bearer\s+[A-Za-z0-9_\-.=]+", "Bearer [REDACTED]", value)
|
| 1051 |
+
value = re.sub(r"hf_[A-Za-z0-9_\-]{10,}", "hf_[REDACTED]", value)
|
| 1052 |
+
return value
|
| 1053 |
+
|
| 1054 |
+
|
| 1055 |
+
def run_cmd(cmd: list[str], *, env: dict | None = None, timeout: int = 120):
|
| 1056 |
+
result = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
| 1057 |
+
return result.returncode, redact_text(result.stdout)
|
| 1058 |
+
|
| 1059 |
+
|
| 1060 |
+
def install_deps(events_path: Path):
|
| 1061 |
+
append_event(events_path, "dependencies", "started", "Installing validation dependencies")
|
| 1062 |
+
code, out = run_cmd([sys.executable, "-m", "pip", "install", "-q", "--upgrade", "huggingface_hub>=1.0.0", "gradio_client>=2.0.0", "requests>=2.31.0"], timeout=600)
|
| 1063 |
+
if code != 0:
|
| 1064 |
+
append_event(events_path, "dependencies", "failed", "Dependency installation failed", {"output_tail": out[-4000:]})
|
| 1065 |
+
raise RuntimeError(out)
|
| 1066 |
+
append_event(events_path, "dependencies", "success", "Validation dependencies installed")
|
| 1067 |
+
|
| 1068 |
+
|
| 1069 |
+
def make_gradio_client(target_space_id: str, token: str):
|
| 1070 |
+
import inspect
|
| 1071 |
+
from gradio_client import Client
|
| 1072 |
+
params = inspect.signature(Client).parameters
|
| 1073 |
+
if "token" in params:
|
| 1074 |
+
return Client(target_space_id, token=token)
|
| 1075 |
+
if "hf_token" in params:
|
| 1076 |
+
return Client(target_space_id, hf_token=token)
|
| 1077 |
+
if "api_key" in params:
|
| 1078 |
+
return Client(target_space_id, api_key=token)
|
| 1079 |
+
if "headers" in params:
|
| 1080 |
+
return Client(target_space_id, headers={"Authorization": f"Bearer {token}"})
|
| 1081 |
+
return Client(target_space_id)
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
def api_names_from_schema(schema) -> list[str]:
|
| 1085 |
+
names: list[str] = []
|
| 1086 |
+
def add(name):
|
| 1087 |
+
if not isinstance(name, str) or not name:
|
| 1088 |
+
return
|
| 1089 |
+
if not name.startswith("/"):
|
| 1090 |
+
name = "/" + name
|
| 1091 |
+
if name not in names:
|
| 1092 |
+
names.append(name)
|
| 1093 |
+
def walk(obj):
|
| 1094 |
+
if isinstance(obj, dict):
|
| 1095 |
+
for k, v in obj.items():
|
| 1096 |
+
if k in {"api_name", "apiName"}:
|
| 1097 |
+
add(v)
|
| 1098 |
+
if isinstance(k, str) and k.startswith("/"):
|
| 1099 |
+
add(k)
|
| 1100 |
+
walk(v)
|
| 1101 |
+
elif isinstance(obj, list):
|
| 1102 |
+
for item in obj:
|
| 1103 |
+
walk(item)
|
| 1104 |
+
walk(schema)
|
| 1105 |
+
return names
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
def runtime_to_dict(runtime) -> dict:
|
| 1109 |
+
payload = {}
|
| 1110 |
+
for attr in ["stage", "hardware", "requested_hardware", "sleep_time", "storage", "gc_timeout"]:
|
| 1111 |
+
value = getattr(runtime, attr, None)
|
| 1112 |
+
payload[attr] = getattr(value, "value", value)
|
| 1113 |
+
return {k: str(v) if v is not None else None for k, v in payload.items()}
|
| 1114 |
+
|
| 1115 |
+
|
| 1116 |
+
def write_space_runtime(api, target_space_id: str, token: str, run_dir: Path, events_path: Path, attempt: int | None = None) -> dict:
|
| 1117 |
+
try:
|
| 1118 |
+
runtime = api.get_space_runtime(repo_id=target_space_id, token=token)
|
| 1119 |
+
payload = runtime_to_dict(runtime)
|
| 1120 |
+
payload["attempt"] = attempt
|
| 1121 |
+
write_json(run_dir / "space_runtime.json", payload)
|
| 1122 |
+
return payload
|
| 1123 |
+
except Exception as exc:
|
| 1124 |
+
payload = {"error": str(exc)[:2000], "attempt": attempt}
|
| 1125 |
+
write_json(run_dir / "space_runtime.json", payload)
|
| 1126 |
+
append_event(events_path, "space_runtime", "warning", "Could not fetch Space runtime", payload)
|
| 1127 |
+
return payload
|
| 1128 |
+
|
| 1129 |
+
|
| 1130 |
+
def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_path: Path):
|
| 1131 |
+
logs_dir = run_dir / "logs"
|
| 1132 |
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
| 1133 |
+
env = os.environ.copy()
|
| 1134 |
+
env["HF_TOKEN"] = token
|
| 1135 |
+
commands = {
|
| 1136 |
+
"space_logs_runtime.txt": ["hf", "spaces", "logs", target_space_id],
|
| 1137 |
+
"space_logs_build.txt": ["hf", "spaces", "logs", target_space_id, "--build"],
|
| 1138 |
+
}
|
| 1139 |
+
written = []
|
| 1140 |
+
for filename, cmd in commands.items():
|
| 1141 |
+
try:
|
| 1142 |
+
code, out = run_cmd(cmd, env=env, timeout=75)
|
| 1143 |
+
(logs_dir / filename).write_text(out, encoding="utf-8")
|
| 1144 |
+
written.append({"file": filename, "returncode": code, "tail": out[-1000:]})
|
| 1145 |
+
except Exception as exc:
|
| 1146 |
+
written.append({"file": filename, "error": str(exc)[:1000]})
|
| 1147 |
+
append_event(events_path, "space_logs", "success", "Collected best-effort Space logs", {"files": written})
|
| 1148 |
+
return written
|
| 1149 |
+
|
| 1150 |
+
|
| 1151 |
+
def space_subdomain_url(target_space_id: str) -> str:
|
| 1152 |
+
owner, name = target_space_id.split("/", 1)
|
| 1153 |
+
return f"https://{owner}-{name}.hf.space".replace("_", "-").lower()
|
| 1154 |
+
|
| 1155 |
+
|
| 1156 |
+
def validate_http_health(target_space_id: str, token: str, run_dir: Path, attempt: int):
|
| 1157 |
+
import requests
|
| 1158 |
+
url = space_subdomain_url(target_space_id).rstrip("/") + "/health"
|
| 1159 |
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json,text/plain,*/*"}
|
| 1160 |
+
response = requests.get(url, headers=headers, timeout=20)
|
| 1161 |
+
payload = {
|
| 1162 |
+
"status": "success" if response.ok else "failed",
|
| 1163 |
+
"attempt": attempt,
|
| 1164 |
+
"url": url,
|
| 1165 |
+
"status_code": response.status_code,
|
| 1166 |
+
"content_type": response.headers.get("content-type"),
|
| 1167 |
+
"text": response.text[:2000],
|
| 1168 |
+
}
|
| 1169 |
+
if response.ok:
|
| 1170 |
+
try:
|
| 1171 |
+
payload["json"] = response.json()
|
| 1172 |
+
except Exception:
|
| 1173 |
+
pass
|
| 1174 |
+
write_json(run_dir / "tests" / "http_health.json", payload)
|
| 1175 |
+
return payload
|
| 1176 |
+
raise RuntimeError(f"HTTP /health returned {response.status_code}: {response.text[:500]}")
|
| 1177 |
+
|
| 1178 |
+
|
| 1179 |
+
def wait_until_live(api, target_space_id: str, token: str, run_dir: Path, events_path: Path, timeout_s: int = 1800):
|
| 1180 |
+
append_event(events_path, "live_wait", "started", "Waiting for existing Space to become live")
|
| 1181 |
+
deadline = time.time() + timeout_s
|
| 1182 |
+
attempt = 0
|
| 1183 |
+
last_error = None
|
| 1184 |
+
while time.time() < deadline:
|
| 1185 |
+
attempt += 1
|
| 1186 |
+
runtime_payload = write_space_runtime(api, target_space_id, token, run_dir, events_path, attempt)
|
| 1187 |
+
stage = str(runtime_payload.get("stage") or "").upper()
|
| 1188 |
+
if "RUNTIME_ERROR" in stage:
|
| 1189 |
+
collect_space_logs(target_space_id, token, run_dir, events_path)
|
| 1190 |
+
last_error = f"Space is in RUNTIME_ERROR: {runtime_payload}"
|
| 1191 |
+
append_event(events_path, "live_wait", "waiting", "Space is in runtime error; still waiting in case hardware was changed manually", {"attempt": attempt, "runtime": runtime_payload})
|
| 1192 |
+
time.sleep(30)
|
| 1193 |
+
continue
|
| 1194 |
+
try:
|
| 1195 |
+
health = validate_http_health(target_space_id, token, run_dir, attempt)
|
| 1196 |
+
append_event(events_path, "live_wait", "success", "HTTP /health is live", {"attempt": attempt})
|
| 1197 |
+
return {"validator": "http_health", "health": health, "runtime": runtime_payload}
|
| 1198 |
+
except Exception as http_exc:
|
| 1199 |
+
last_error = f"HTTP health failed: {http_exc}"
|
| 1200 |
+
try:
|
| 1201 |
+
client = make_gradio_client(target_space_id, token)
|
| 1202 |
+
schema = client.view_api(return_format="dict")
|
| 1203 |
+
names = api_names_from_schema(schema)
|
| 1204 |
+
write_json(run_dir / "tests" / "api_schema.json", {"schema": schema, "api_names": names})
|
| 1205 |
+
if names:
|
| 1206 |
+
append_event(events_path, "live_wait", "success", "Gradio API schema is live", {"attempt": attempt, "api_names": names})
|
| 1207 |
+
return {"validator": "gradio_schema", "api_names": names, "runtime": runtime_payload}
|
| 1208 |
+
except Exception as gr_exc:
|
| 1209 |
+
last_error = (last_error or "") + f"; Gradio schema failed: {gr_exc}"
|
| 1210 |
+
append_event(events_path, "live_wait", "waiting", "Space not live yet", {"attempt": attempt, "runtime": runtime_payload, "error": last_error[-1500:] if last_error else None})
|
| 1211 |
+
time.sleep(30)
|
| 1212 |
+
collect_space_logs(target_space_id, token, run_dir, events_path)
|
| 1213 |
+
raise RuntimeError(f"Space did not become live before timeout: {last_error}")
|
| 1214 |
+
|
| 1215 |
+
|
| 1216 |
+
def parse_json_env(name: str, default):
|
| 1217 |
+
value = os.environ.get(name)
|
| 1218 |
+
if not value:
|
| 1219 |
+
return default
|
| 1220 |
+
try:
|
| 1221 |
+
return json.loads(value)
|
| 1222 |
+
except Exception as exc:
|
| 1223 |
+
raise ValueError(f"Invalid JSON for {name}: {exc}")
|
| 1224 |
+
|
| 1225 |
+
|
| 1226 |
+
def result_contains_expected_output(result, expected_output_type: str) -> tuple[bool, dict]:
|
| 1227 |
+
expected = (expected_output_type or "any").lower().strip()
|
| 1228 |
+
info = {"expected_output_type": expected, "result_type": type(result).__name__, "result_repr": repr(result)[:2000]}
|
| 1229 |
+
paths = []
|
| 1230 |
+
def visit(obj):
|
| 1231 |
+
if isinstance(obj, (str, Path)):
|
| 1232 |
+
text = str(obj)
|
| 1233 |
+
if any(text.lower().endswith(ext) for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".wav", ".mp3", ".txt"]):
|
| 1234 |
+
paths.append(text)
|
| 1235 |
+
elif isinstance(obj, dict):
|
| 1236 |
+
for key in ["path", "url", "name"]:
|
| 1237 |
+
if key in obj:
|
| 1238 |
+
visit(obj[key])
|
| 1239 |
+
for value in obj.values():
|
| 1240 |
+
if isinstance(value, (dict, list, tuple)):
|
| 1241 |
+
visit(value)
|
| 1242 |
+
elif isinstance(obj, (list, tuple)):
|
| 1243 |
+
for item in obj:
|
| 1244 |
+
visit(item)
|
| 1245 |
+
visit(result)
|
| 1246 |
+
info["detected_paths"] = paths[:20]
|
| 1247 |
+
if expected == "any":
|
| 1248 |
+
return result is not None, info
|
| 1249 |
+
image_ext = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
|
| 1250 |
+
video_ext = [".mp4", ".mov", ".webm"]
|
| 1251 |
+
audio_ext = [".wav", ".mp3", ".flac", ".ogg"]
|
| 1252 |
+
if expected == "text":
|
| 1253 |
+
return isinstance(result, str) and len(result.strip()) > 0, info
|
| 1254 |
+
if expected == "image":
|
| 1255 |
+
return any(str(p).lower().endswith(tuple(image_ext)) for p in paths), info
|
| 1256 |
+
if expected == "video":
|
| 1257 |
+
return any(str(p).lower().endswith(tuple(video_ext)) for p in paths), info
|
| 1258 |
+
if expected == "audio":
|
| 1259 |
+
return any(str(p).lower().endswith(tuple(audio_ext)) for p in paths), info
|
| 1260 |
+
return result is not None, info
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
def copy_result_artifacts(result, run_dir: Path):
|
| 1264 |
+
artifacts = run_dir / "artifacts"
|
| 1265 |
+
artifacts.mkdir(parents=True, exist_ok=True)
|
| 1266 |
+
copied = []
|
| 1267 |
+
def maybe_copy(obj):
|
| 1268 |
+
if isinstance(obj, (str, Path)):
|
| 1269 |
+
path = Path(str(obj))
|
| 1270 |
+
if path.exists() and path.is_file():
|
| 1271 |
+
target = artifacts / path.name
|
| 1272 |
+
try:
|
| 1273 |
+
shutil.copy2(path, target)
|
| 1274 |
+
copied.append(str(target))
|
| 1275 |
+
except Exception:
|
| 1276 |
+
pass
|
| 1277 |
+
elif isinstance(obj, dict):
|
| 1278 |
+
for key in ["path", "name"]:
|
| 1279 |
+
if key in obj:
|
| 1280 |
+
maybe_copy(obj[key])
|
| 1281 |
+
for value in obj.values():
|
| 1282 |
+
if isinstance(value, (dict, list, tuple)):
|
| 1283 |
+
maybe_copy(value)
|
| 1284 |
+
elif isinstance(obj, (list, tuple)):
|
| 1285 |
+
for item in obj:
|
| 1286 |
+
maybe_copy(item)
|
| 1287 |
+
maybe_copy(result)
|
| 1288 |
+
return copied
|
| 1289 |
+
|
| 1290 |
+
|
| 1291 |
+
def smoke_generate(target_space_id: str, token: str, run_dir: Path, events_path: Path):
|
| 1292 |
+
api_name = (os.environ.get("API_NAME") or "/generate").strip()
|
| 1293 |
+
expected_output_type = (os.environ.get("EXPECTED_OUTPUT_TYPE") or "any").strip()
|
| 1294 |
+
test_args = parse_json_env("TEST_ARGS_JSON", ["a cinematic robot cat astronaut, detailed, studio lighting"])
|
| 1295 |
+
test_kwargs = parse_json_env("TEST_KWARGS_JSON", {})
|
| 1296 |
+
if not isinstance(test_args, list):
|
| 1297 |
+
raise ValueError("TEST_ARGS_JSON must be a JSON list")
|
| 1298 |
+
if not isinstance(test_kwargs, dict):
|
| 1299 |
+
raise ValueError("TEST_KWARGS_JSON must be a JSON object")
|
| 1300 |
+
append_event(events_path, "generation_smoke", "started", "Calling live generation endpoint", {"api_name": api_name, "expected_output_type": expected_output_type})
|
| 1301 |
+
client = make_gradio_client(target_space_id, token)
|
| 1302 |
+
schema = client.view_api(return_format="dict")
|
| 1303 |
+
discovered = api_names_from_schema(schema)
|
| 1304 |
+
write_json(run_dir / "tests" / "api_schema.json", {"schema": schema, "api_names": discovered})
|
| 1305 |
+
started = time.time()
|
| 1306 |
+
result = client.predict(*test_args, api_name=api_name, **test_kwargs)
|
| 1307 |
+
latency = time.time() - started
|
| 1308 |
+
ok, info = result_contains_expected_output(result, expected_output_type)
|
| 1309 |
+
copied = copy_result_artifacts(result, run_dir)
|
| 1310 |
+
payload = {
|
| 1311 |
+
"status": "success" if ok else "failed",
|
| 1312 |
+
"target_space": target_space_id,
|
| 1313 |
+
"api_name": api_name,
|
| 1314 |
+
"discovered_api_names": discovered,
|
| 1315 |
+
"test_args": test_args,
|
| 1316 |
+
"test_kwargs": test_kwargs,
|
| 1317 |
+
"expected_output_type": expected_output_type,
|
| 1318 |
+
"latency_seconds": round(latency, 3),
|
| 1319 |
+
"result_info": info,
|
| 1320 |
+
"copied_artifacts": copied,
|
| 1321 |
+
"recommended_zero_gpu_duration_seconds": int(max(30, min(300, latency * 2 + 15))),
|
| 1322 |
+
"validated_at": now(),
|
| 1323 |
+
}
|
| 1324 |
+
write_json(run_dir / "tests" / "generation_smoke.json", payload)
|
| 1325 |
+
write_json(run_dir / "tests" / "test_result.json", payload)
|
| 1326 |
+
if ok:
|
| 1327 |
+
append_event(events_path, "generation_smoke", "success", "Live generation smoke test passed", {"latency_seconds": payload["latency_seconds"], "copied_artifacts": copied[:5]})
|
| 1328 |
+
return payload
|
| 1329 |
+
append_event(events_path, "generation_smoke", "failed", "Live generation returned an unexpected output type", payload)
|
| 1330 |
+
raise RuntimeError("Generation smoke test failed: unexpected output type")
|
| 1331 |
+
|
| 1332 |
+
|
| 1333 |
+
def main():
|
| 1334 |
+
run_id = os.environ["RUN_ID"]
|
| 1335 |
+
username = os.environ.get("HF_USERNAME", "unknown")
|
| 1336 |
+
output_root = Path(os.environ.get("OUTPUT_ROOT", "/output"))
|
| 1337 |
+
target_space_id = os.environ["TARGET_SPACE_ID"].strip()
|
| 1338 |
+
token = os.environ.get("HF_TOKEN")
|
| 1339 |
+
run_dir = output_root / "runs" / run_id
|
| 1340 |
+
events_path = run_dir / "events.jsonl"
|
| 1341 |
+
state_path = run_dir / "state.json"
|
| 1342 |
+
append_event(events_path, "bootstrap", "started", "Existing Space validation worker started", {"target_space_id": target_space_id})
|
| 1343 |
+
write_json(state_path, {"run_id": run_id, "kind": "validate_existing_space", "status": "running", "target_space": target_space_id, "created_by": username, "updated_at": now()})
|
| 1344 |
+
if not token:
|
| 1345 |
+
raise RuntimeError("HF_TOKEN is missing")
|
| 1346 |
+
if not TARGET_RE.match(target_space_id):
|
| 1347 |
+
raise ValueError("TARGET_SPACE_ID must look like owner/space-name")
|
| 1348 |
+
try:
|
| 1349 |
+
install_deps(events_path)
|
| 1350 |
+
from huggingface_hub import HfApi
|
| 1351 |
+
api = HfApi(token=token)
|
| 1352 |
+
whoami = api.whoami(token=token)
|
| 1353 |
+
append_event(events_path, "auth", "success", "Authenticated inside validation Job", {"whoami_name": whoami.get("name")})
|
| 1354 |
+
live = wait_until_live(api, target_space_id, token, run_dir, events_path, timeout_s=int(os.environ.get("LIVE_TIMEOUT_SECONDS", "1800")))
|
| 1355 |
+
smoke = smoke_generate(target_space_id, token, run_dir, events_path)
|
| 1356 |
+
final_state = {
|
| 1357 |
+
"run_id": run_id,
|
| 1358 |
+
"kind": "validate_existing_space",
|
| 1359 |
+
"status": "full_inference_success",
|
| 1360 |
+
"message": "Existing Space passed live health/schema validation and generation smoke test.",
|
| 1361 |
+
"target_space": target_space_id,
|
| 1362 |
+
"target_space_url": f"https://huggingface.co/spaces/{target_space_id}",
|
| 1363 |
+
"live_validation": live,
|
| 1364 |
+
"generation_smoke": smoke,
|
| 1365 |
+
"updated_at": now(),
|
| 1366 |
+
}
|
| 1367 |
+
write_json(state_path, final_state)
|
| 1368 |
+
report = f"""# Agentic Space Factory — Existing Space Validation Report
|
| 1369 |
+
|
| 1370 |
+
Status: **full_inference_success**
|
| 1371 |
+
|
| 1372 |
+
Target Space: [`{target_space_id}`](https://huggingface.co/spaces/{target_space_id})
|
| 1373 |
+
|
| 1374 |
+
## Generation smoke test
|
| 1375 |
+
|
| 1376 |
+
```json
|
| 1377 |
+
{json.dumps(smoke, indent=2, ensure_ascii=False)}
|
| 1378 |
+
```
|
| 1379 |
+
|
| 1380 |
+
## Notes
|
| 1381 |
+
|
| 1382 |
+
- This validation is intended for Spaces whose hardware was set manually after generation.
|
| 1383 |
+
- Latency is measured from the live Gradio endpoint call.
|
| 1384 |
+
- The recommended ZeroGPU duration is a rough estimate from this live run, not a guarantee.
|
| 1385 |
+
"""
|
| 1386 |
+
(run_dir / "report.md").write_text(report, encoding="utf-8")
|
| 1387 |
+
append_event(events_path, "report_write", "success", "Wrote report.md")
|
| 1388 |
+
append_event(events_path, "done", "full_inference_success", "Existing Space validation completed", {"latency_seconds": smoke.get("latency_seconds")})
|
| 1389 |
+
except Exception as exc:
|
| 1390 |
+
collect_space_logs(target_space_id, token or "", run_dir, events_path)
|
| 1391 |
+
details = {"error": str(exc)[:4000]}
|
| 1392 |
+
write_json(state_path, {"run_id": run_id, "kind": "validate_existing_space", "status": "failed", "target_space": target_space_id, "details": details, "updated_at": now()})
|
| 1393 |
+
(run_dir / "report.md").write_text(f"# Existing Space Validation Failed\n\n```json\n{json.dumps(details, indent=2, ensure_ascii=False)}\n```\n", encoding="utf-8")
|
| 1394 |
+
append_event(events_path, "failure", "failed", "Existing Space validation failed", details)
|
| 1395 |
+
raise SystemExit(1)
|
| 1396 |
+
|
| 1397 |
+
|
| 1398 |
+
if __name__ == "__main__":
|
| 1399 |
+
main()
|
| 1400 |
+
'''
|
| 1401 |
+
|
| 1402 |
+
|
| 1403 |
+
def encoded_universal_model_card_worker_script() -> str:
|
| 1404 |
+
"""Return the base64-encoded universal model-card builder worker script."""
|
| 1405 |
+
return _encode(UNIVERSAL_MODEL_CARD_WORKER_SCRIPT)
|
| 1406 |
+
|
| 1407 |
+
|
| 1408 |
+
def encoded_validate_existing_space_worker_script() -> str:
|
| 1409 |
+
"""Return the base64-encoded existing-Space validation worker script."""
|
| 1410 |
+
return _encode(VALIDATE_EXISTING_SPACE_WORKER_SCRIPT)
|
| 1411 |
+
|
| 1412 |
+
|
| 1413 |
+
def python_decode_and_run_command() -> list[str]:
|
| 1414 |
+
"""Command list for `run_job`.
|
| 1415 |
+
|
| 1416 |
+
The Job image only needs Python. The script is passed via env as base64 and
|
| 1417 |
+
executed from /tmp, which avoids persisting code or exposing secrets.
|
| 1418 |
+
"""
|
| 1419 |
+
runner = textwrap.dedent(
|
| 1420 |
+
"""
|
| 1421 |
+
import base64, os, pathlib, subprocess, sys
|
| 1422 |
+
script = base64.b64decode(os.environ['WORKER_SCRIPT_B64']).decode('utf-8')
|
| 1423 |
+
path = pathlib.Path('/tmp/space_factory_worker.py')
|
| 1424 |
+
path.write_text(script, encoding='utf-8')
|
| 1425 |
+
raise SystemExit(subprocess.call([sys.executable, str(path)]))
|
| 1426 |
+
"""
|
| 1427 |
+
).strip()
|
| 1428 |
+
return ["python", "-c", runner]
|