fffiloni commited on
Commit
6e53100
·
verified ·
1 Parent(s): 35e9bf4

Upload 8 files

Browse files
Files changed (8) hide show
  1. src/auth.py +199 -0
  2. src/bucket.py +300 -0
  3. src/config.py +40 -0
  4. src/jobs.py +218 -0
  5. src/progress.py +197 -0
  6. src/runs.py +23 -0
  7. src/security.py +24 -0
  8. 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]