fffiloni commited on
Commit
6f6791d
·
verified ·
1 Parent(s): 796e10b

Upload 10 files

Browse files
Files changed (2) hide show
  1. src/bucket.py +207 -24
  2. src/worker_payload.py +193 -29
src/bucket.py CHANGED
@@ -162,10 +162,125 @@ def append_run_event(run_id: str, *, bucket_source: str, step: str, status: str,
162
  return event
163
 
164
 
165
- def delete_run_folder(run_id: str, *, bucket_source: str, token: str | None = None) -> None:
166
- """Delete a run folder from the private bucket."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  paths = RunPaths(run_id, bucket_source=bucket_source)
168
- _fs(token).rm(paths.root, recursive=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
 
171
  def write_launch_metadata(run_id: str, *, bucket_source: str, payload: dict[str, Any], token: str | None = None) -> None:
@@ -359,8 +474,29 @@ def _bucket_tree_url(bucket_source: str, run_id: str, rel_path: str = "") -> str
359
 
360
 
361
  def _path_exists(path: str, token: str | None = None) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  try:
363
- return bool(_fs(token).exists(path))
364
  except Exception:
365
  return False
366
 
@@ -372,6 +508,25 @@ def _has_glob(pattern: str, token: str | None = None) -> bool:
372
  return False
373
 
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, Any], token: str | None = None) -> list[dict[str, Any]]:
376
  """Return the small document dock shown under Active Run events.
377
 
@@ -391,31 +546,51 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
391
  paths = RunPaths(run_id, bucket_source=bucket_source)
392
  files = [f for f in (bundle.get("files") or []) if isinstance(f, dict)]
393
  file_paths = {str(f.get("path") or "") for f in files}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
  def trace_folder_has_content(prefix: str) -> bool:
396
  rel_prefix = prefix.rstrip("/") + "/"
397
  if any(path.startswith(rel_prefix) and not path.endswith("/") for path in file_paths):
398
  return True
399
- root_prefix = f"{paths.root}/{rel_prefix}"
400
- for pattern in (f"{root_prefix}*", f"{root_prefix}**/*"):
401
- try:
402
- found = [str(path) for path in _fs(token).glob(pattern)]
403
- except Exception:
404
- found = []
405
- if any(item.replace(paths.root + "/", "", 1).startswith(rel_prefix) for item in found):
406
- return True
407
- return False
408
 
409
  raw_present = trace_folder_has_content("traces/raw")
410
  redacted_present = trace_folder_has_content("traces/redacted")
411
  raw_trace_url = _bucket_tree_url(bucket_source, run_id, "traces/raw")
412
  redacted_trace_url = _bucket_tree_url(bucket_source, run_id, "traces/redacted")
413
- smoke_present = bool(bundle.get("generation_smoke")) or "tests/generation_smoke.json" in file_paths or _path_exists(f"{paths.root}/tests/generation_smoke.json", token=token)
414
- report_present = bool(bundle.get("report")) or _path_exists(paths.report, token=token)
415
- blockers_present = bool(bundle.get("technical_blockers")) or "generated/TECHNICAL_BLOCKERS.json" in file_paths or _path_exists(f"{paths.root}/generated/TECHNICAL_BLOCKERS.json", token=token) or _path_exists(f"{paths.root}/TECHNICAL_BLOCKERS.json", token=token)
416
- repair_decision_present = "repair/REPAIR_DECISION.json" in file_paths or _path_exists(f"{paths.root}/repair/REPAIR_DECISION.json", token=token)
417
- blockage_present = "repair/BLOCKAGE.json" in file_paths or _path_exists(f"{paths.root}/repair/BLOCKAGE.json", token=token)
418
- repair_present = any(path.startswith("repair/") for path in file_paths) or _path_exists(f"{paths.root}/repair/REPAIR_SUMMARY.md", token=token)
 
 
419
  status = str((bundle.get("summary") or {}).get("status") or (bundle.get("state") or {}).get("status") or "").lower()
420
  blockers_relevant = blockers_present or any(marker in status for marker in ("manual", "blocker", "failed", "error"))
421
 
@@ -452,7 +627,7 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
452
  "subtitle": "Validation + latency",
453
  "icon": "⚡",
454
  "present": smoke_present,
455
- "url": _bucket_file_url(bucket_source, run_id, "tests/generation_smoke.json"),
456
  },
457
  ]
458
  if repair_decision_present:
@@ -499,7 +674,7 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
499
  "subtitle": "Why inference is blocked",
500
  "icon": "⚠️",
501
  "present": blockers_present,
502
- "url": _bucket_file_url(bucket_source, run_id, "generated/TECHNICAL_BLOCKERS.json"),
503
  "tone": "warn",
504
  }
505
  )
@@ -507,7 +682,10 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
507
 
508
 
509
  def _normalize_model_name(value: str | None) -> str:
510
- return re.sub(r"[^a-z0-9]+", "", (value or "").lower())
 
 
 
511
 
512
 
513
  def _extract_pi_models_from_text(text: str) -> list[str]:
@@ -516,11 +694,14 @@ def _extract_pi_models_from_text(text: str) -> list[str]:
516
  patterns = [
517
  r"\bQwen/[A-Za-z0-9_.-]+",
518
  r"\bQwen(?:2(?:\.5)?|3)?[-_/A-Za-z0-9.]*Coder[-_/A-Za-z0-9.]*",
 
519
  r"\bKimi[-_/A-Za-z0-9.]+",
520
- r"\bMoonshotAI/[A-Za-z0-9_.-]+",
 
 
 
521
  r"\bClaude[-_/A-Za-z0-9.]+",
522
  r"\bGPT[-_/A-Za-z0-9.]+",
523
- r"\bDeepSeek[-_/A-Za-z0-9.]+",
524
  r"\b[Mm]odel(?:\\s+used|\\s+selected|\\s*:)?\\s*[:=]?\\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)",
525
  ]
526
  found: list[str] = []
@@ -603,6 +784,7 @@ def summarize_run_bundle(run_id: str, bundle: dict[str, Any], *, bucket_source:
603
  "kind": state.get("kind") or launch.get("kind") or summary_file.get("kind") or "unknown",
604
  "status": status,
605
  "model_id": state.get("model_id") or launch.get("model_id") or summary_file.get("model_id") or state.get("model") or "",
 
606
  "target_space": target_space,
607
  "target_space_url": state.get("target_space_url") or launch.get("target_space_url") or summary_file.get("target_space_url") or (f"https://huggingface.co/spaces/{target_space}" if target_space else ""),
608
  "job_id": state.get("job_id") or launch.get("job_id") or summary_file.get("job_id") or "",
@@ -649,6 +831,7 @@ def read_run_bundle(run_id: str, *, bucket_source: str, token: str | None = None
649
  "api_schema": _safe_read_json(f"{paths.root}/tests/api_schema.json", token=token) or _safe_read_json(f"{paths.root}/tests/generation_api_schema.json", token=token),
650
  "repair_decision": _safe_read_json(f"{paths.root}/repair/REPAIR_DECISION.json", token=token),
651
  "blockage": _safe_read_json(f"{paths.root}/repair/BLOCKAGE.json", token=token),
 
652
  "files": _list_run_files(run_id, bucket_source=bucket_source, token=token) if include_heavy else [],
653
  }
654
  bundle["run_documents"] = _run_document_links(run_id, bucket_source=bucket_source, bundle=bundle, token=token)
 
162
  return event
163
 
164
 
165
+ def _bucket_path_kind(entry: Any) -> tuple[str, str | None]:
166
+ """Return ``(path, type)`` for HfFileSystem listing entries.
167
+
168
+ HfFileSystem may return plain strings or fsspec-style dictionaries, and
169
+ bucket directory objects are virtual. Centralise the normalisation so run
170
+ deletion can remove concrete files without depending on a single listing
171
+ shape.
172
+ """
173
+ if isinstance(entry, dict):
174
+ return str(entry.get("name") or entry.get("path") or ""), entry.get("type")
175
+ return str(entry or ""), None
176
+
177
+
178
+ def _collect_run_folder_files(fs: HfFileSystem, root: str) -> list[str]:
179
+ """Collect concrete bucket objects under a run prefix.
180
+
181
+ A single ``rm(root, recursive=True)`` can be unreliable on HF Storage
182
+ Buckets because the run folder is a virtual prefix. Prefer explicit
183
+ recursive discovery, with several fallbacks for older/partial filesystem
184
+ behaviours.
185
+ """
186
+ root = root.rstrip("/")
187
+ found: set[str] = set()
188
+
189
+ # Best source when available: recursive find with fresh listing.
190
+ try:
191
+ entries = fs.find(root, withdirs=False, detail=False, refresh=True)
192
+ for entry in entries or []:
193
+ path, kind = _bucket_path_kind(entry)
194
+ if path and path != root and not path.endswith("/") and kind != "directory":
195
+ found.add(path)
196
+ except Exception:
197
+ pass
198
+
199
+ # Glob fallback catches buckets where find is incomplete or disabled.
200
+ for pattern in (f"{root}/**", f"{root}/**/*", f"{root}/*"):
201
+ try:
202
+ for entry in fs.glob(pattern) or []:
203
+ path, kind = _bucket_path_kind(entry)
204
+ if path and path != root and not path.endswith("/") and kind != "directory":
205
+ found.add(path)
206
+ except Exception:
207
+ pass
208
+
209
+ # Manual recursive ls fallback. Keep it iterative to avoid recursion loops
210
+ # if the remote returns the same virtual prefix multiple times.
211
+ pending = [root]
212
+ seen_dirs: set[str] = set()
213
+ while pending:
214
+ prefix = pending.pop().rstrip("/")
215
+ if prefix in seen_dirs:
216
+ continue
217
+ seen_dirs.add(prefix)
218
+ try:
219
+ entries = fs.ls(prefix, detail=True, refresh=True) or []
220
+ except Exception:
221
+ continue
222
+ for entry in entries:
223
+ path, kind = _bucket_path_kind(entry)
224
+ if not path or path == prefix:
225
+ continue
226
+ if kind == "directory" or path.endswith("/"):
227
+ pending.append(path.rstrip("/"))
228
+ else:
229
+ found.add(path)
230
+
231
+ return sorted(found, key=lambda value: value.count("/"), reverse=True)
232
+
233
+
234
+ def delete_run_folder(run_id: str, *, bucket_source: str, token: str | None = None) -> dict[str, Any]:
235
+ """Delete every concrete object under a run prefix from the private bucket.
236
+
237
+ Returns a small report so the API and tests can distinguish a real bucket
238
+ cleanup from a UI-only tombstone. The generated/tested Space is never
239
+ deleted here; only ``runs/<run_id>/`` bucket artifacts are removed.
240
+ """
241
  paths = RunPaths(run_id, bucket_source=bucket_source)
242
+ root = paths.root.rstrip("/")
243
+ fs = _fs(token)
244
+ files = _collect_run_folder_files(fs, root)
245
+ deleted: list[str] = []
246
+ errors: list[str] = []
247
+
248
+ for path in files:
249
+ try:
250
+ fs.rm(path, recursive=False)
251
+ deleted.append(path)
252
+ except FileNotFoundError:
253
+ deleted.append(path)
254
+ except Exception as exc: # noqa: BLE001
255
+ errors.append(f"{path}: {redact(str(exc))}")
256
+
257
+ # Keep the old recursive call as a final cleanup for any filesystem that
258
+ # does support virtual directory removal, but never rely on it as the only
259
+ # deletion mechanism.
260
+ try:
261
+ fs.rm(root, recursive=True)
262
+ except FileNotFoundError:
263
+ pass
264
+ except Exception as exc: # noqa: BLE001
265
+ # If explicit file deletion succeeded, a virtual-directory cleanup error
266
+ # should not make the whole operation fail. It is useful diagnostic
267
+ # metadata though, so report it when nothing was deleted.
268
+ if not deleted and not files:
269
+ errors.append(f"{root}: {redact(str(exc))}")
270
+
271
+ remaining = _collect_run_folder_files(fs, root)
272
+ if remaining:
273
+ errors.append(f"{len(remaining)} object(s) still present under runs/{run_id}")
274
+
275
+ if errors:
276
+ raise RuntimeError("; ".join(errors))
277
+
278
+ return {
279
+ "root": root,
280
+ "matched_count": len(files),
281
+ "deleted_count": len(deleted),
282
+ "remaining_count": len(remaining),
283
+ }
284
 
285
 
286
  def write_launch_metadata(run_id: str, *, bucket_source: str, payload: dict[str, Any], token: str | None = None) -> None:
 
474
 
475
 
476
  def _path_exists(path: str, token: str | None = None) -> bool:
477
+ """Best-effort existence check for Bucket objects.
478
+
479
+ HfFileSystem.exists() can lag or return false for recently written Bucket
480
+ objects depending on the backend view used by a running Job. For UI affordances
481
+ such as the Run traces dock, a false negative is worse than a slightly slower
482
+ check, so fall back to parent listings and glob probes.
483
+ """
484
+ fs = _fs(token)
485
+ try:
486
+ if bool(fs.exists(path)):
487
+ return True
488
+ except Exception:
489
+ pass
490
+ try:
491
+ parent, name = path.rsplit("/", 1)
492
+ for entry in fs.ls(parent, detail=True):
493
+ entry_name = entry.get("name") if isinstance(entry, dict) else str(entry)
494
+ if str(entry_name).rstrip("/") == path.rstrip("/") or str(entry_name).rstrip("/").endswith("/" + name):
495
+ return True
496
+ except Exception:
497
+ pass
498
  try:
499
+ return bool(list(fs.glob(path))[:1])
500
  except Exception:
501
  return False
502
 
 
508
  return False
509
 
510
 
511
+ def _folder_has_content(path: str, token: str | None = None) -> bool:
512
+ """Return true when a Bucket folder/prefix contains at least one object."""
513
+ fs = _fs(token)
514
+ prefix = path.rstrip("/")
515
+ for probe in (f"{prefix}/agent_trace.jsonl", f"{prefix}/events.jsonl"):
516
+ if _path_exists(probe, token=token):
517
+ return True
518
+ try:
519
+ entries = fs.ls(prefix, detail=True)
520
+ if entries:
521
+ return True
522
+ except Exception:
523
+ pass
524
+ for pattern in (f"{prefix}/*", f"{prefix}/**/*"):
525
+ if _has_glob(pattern, token=token):
526
+ return True
527
+ return False
528
+
529
+
530
  def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, Any], token: str | None = None) -> list[dict[str, Any]]:
531
  """Return the small document dock shown under Active Run events.
532
 
 
546
  paths = RunPaths(run_id, bucket_source=bucket_source)
547
  files = [f for f in (bundle.get("files") or []) if isinstance(f, dict)]
548
  file_paths = {str(f.get("path") or "") for f in files}
549
+ manifest = bundle.get("artifact_manifest") or {}
550
+ manifest_artifacts = [a for a in (manifest.get("artifacts") or []) if isinstance(a, dict)] if isinstance(manifest, dict) else []
551
+ manifest_paths = {str(a.get("path") or "") for a in manifest_artifacts if a.get("present")}
552
+
553
+ def manifest_has(path: str) -> bool:
554
+ path = path.strip("/")
555
+ prefix = path.rstrip("/") + "/"
556
+ return path in manifest_paths or any(p.startswith(prefix) for p in manifest_paths)
557
+
558
+ def first_existing_path(*candidates: str) -> str:
559
+ for candidate in candidates:
560
+ clean = candidate.strip("/")
561
+ if clean in file_paths or clean in manifest_paths:
562
+ return clean
563
+ if token:
564
+ for candidate in candidates:
565
+ clean = candidate.strip("/")
566
+ if _path_exists(f"{paths.root}/{clean}", token=token):
567
+ return clean
568
+ return candidates[0].strip("/") if candidates else ""
569
 
570
  def trace_folder_has_content(prefix: str) -> bool:
571
  rel_prefix = prefix.rstrip("/") + "/"
572
  if any(path.startswith(rel_prefix) and not path.endswith("/") for path in file_paths):
573
  return True
574
+ if manifest_has(prefix):
575
+ return True
576
+ if not token:
577
+ return False
578
+ root_prefix = f"{paths.root}/{prefix.rstrip('/')}"
579
+ # _folder_has_content probes direct children and recursive **/* patterns via _fs(token).glob(pattern).
580
+ return _folder_has_content(root_prefix, token=token)
 
 
581
 
582
  raw_present = trace_folder_has_content("traces/raw")
583
  redacted_present = trace_folder_has_content("traces/redacted")
584
  raw_trace_url = _bucket_tree_url(bucket_source, run_id, "traces/raw")
585
  redacted_trace_url = _bucket_tree_url(bucket_source, run_id, "traces/redacted")
586
+ smoke_path = first_existing_path("tests/generation_smoke.json", "generation_smoke.json")
587
+ blockers_path = first_existing_path("generated/TECHNICAL_BLOCKERS.json", "TECHNICAL_BLOCKERS.json")
588
+ smoke_present = bool(bundle.get("generation_smoke")) or manifest_has("tests/generation_smoke.json") or manifest_has("generation_smoke.json") or "tests/generation_smoke.json" in file_paths or "generation_smoke.json" in file_paths or smoke_path in file_paths
589
+ report_present = bool(bundle.get("report")) or manifest_has("report.md") or "report.md" in file_paths or (bool(token) and _path_exists(paths.report, token=token))
590
+ blockers_present = bool(bundle.get("technical_blockers")) or manifest_has("generated/TECHNICAL_BLOCKERS.json") or manifest_has("TECHNICAL_BLOCKERS.json") or "generated/TECHNICAL_BLOCKERS.json" in file_paths or "TECHNICAL_BLOCKERS.json" in file_paths or blockers_path in file_paths
591
+ repair_decision_present = manifest_has("repair/REPAIR_DECISION.json") or "repair/REPAIR_DECISION.json" in file_paths or (bool(token) and _path_exists(f"{paths.root}/repair/REPAIR_DECISION.json", token=token))
592
+ blockage_present = manifest_has("repair/BLOCKAGE.json") or "repair/BLOCKAGE.json" in file_paths or (bool(token) and _path_exists(f"{paths.root}/repair/BLOCKAGE.json", token=token))
593
+ repair_present = manifest_has("repair") or any(path.startswith("repair/") for path in file_paths) or (bool(token) and _path_exists(f"{paths.root}/repair/REPAIR_SUMMARY.md", token=token))
594
  status = str((bundle.get("summary") or {}).get("status") or (bundle.get("state") or {}).get("status") or "").lower()
595
  blockers_relevant = blockers_present or any(marker in status for marker in ("manual", "blocker", "failed", "error"))
596
 
 
627
  "subtitle": "Validation + latency",
628
  "icon": "⚡",
629
  "present": smoke_present,
630
+ "url": _bucket_file_url(bucket_source, run_id, smoke_path),
631
  },
632
  ]
633
  if repair_decision_present:
 
674
  "subtitle": "Why inference is blocked",
675
  "icon": "⚠️",
676
  "present": blockers_present,
677
+ "url": _bucket_file_url(bucket_source, run_id, blockers_path),
678
  "tone": "warn",
679
  }
680
  )
 
682
 
683
 
684
  def _normalize_model_name(value: str | None) -> str:
685
+ raw = (value or "").strip().lower()
686
+ if "/" in raw:
687
+ raw = raw.rsplit("/", 1)[-1]
688
+ return re.sub(r"[^a-z0-9]+", "", raw)
689
 
690
 
691
  def _extract_pi_models_from_text(text: str) -> list[str]:
 
694
  patterns = [
695
  r"\bQwen/[A-Za-z0-9_.-]+",
696
  r"\bQwen(?:2(?:\.5)?|3)?[-_/A-Za-z0-9.]*Coder[-_/A-Za-z0-9.]*",
697
+ r"\bmoonshotai/[A-Za-z0-9_.-]+",
698
  r"\bKimi[-_/A-Za-z0-9.]+",
699
+ r"\bzai-org/[A-Za-z0-9_.-]+",
700
+ r"\bGLM[-_/A-Za-z0-9.]+",
701
+ r"\bdeepseek-ai/[A-Za-z0-9_.-]+",
702
+ r"\bDeepSeek[-_/A-Za-z0-9.]+",
703
  r"\bClaude[-_/A-Za-z0-9.]+",
704
  r"\bGPT[-_/A-Za-z0-9.]+",
 
705
  r"\b[Mm]odel(?:\\s+used|\\s+selected|\\s*:)?\\s*[:=]?\\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)",
706
  ]
707
  found: list[str] = []
 
784
  "kind": state.get("kind") or launch.get("kind") or summary_file.get("kind") or "unknown",
785
  "status": status,
786
  "model_id": state.get("model_id") or launch.get("model_id") or summary_file.get("model_id") or state.get("model") or "",
787
+ "pi_model": state.get("pi_model") or launch.get("pi_model") or summary_file.get("pi_model") or (bundle.get("pi_model_resolution") or {}).get("requested_model") or "",
788
  "target_space": target_space,
789
  "target_space_url": state.get("target_space_url") or launch.get("target_space_url") or summary_file.get("target_space_url") or (f"https://huggingface.co/spaces/{target_space}" if target_space else ""),
790
  "job_id": state.get("job_id") or launch.get("job_id") or summary_file.get("job_id") or "",
 
831
  "api_schema": _safe_read_json(f"{paths.root}/tests/api_schema.json", token=token) or _safe_read_json(f"{paths.root}/tests/generation_api_schema.json", token=token),
832
  "repair_decision": _safe_read_json(f"{paths.root}/repair/REPAIR_DECISION.json", token=token),
833
  "blockage": _safe_read_json(f"{paths.root}/repair/BLOCKAGE.json", token=token),
834
+ "artifact_manifest": _safe_read_json(f"{paths.root}/artifact_manifest.json", token=token),
835
  "files": _list_run_files(run_id, bucket_source=bucket_source, token=token) if include_heavy else [],
836
  }
837
  bundle["run_documents"] = _run_document_links(run_id, bucket_source=bucket_source, bundle=bundle, token=token)
src/worker_payload.py CHANGED
@@ -88,6 +88,89 @@ def append_event(path: Path, step: str, status: str, message: str, data: dict |
88
  print(line, flush=True)
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def redact_text(text: str | None) -> str:
92
  if not text:
93
  return ""
@@ -249,25 +332,38 @@ def exception_payload(exc: Exception) -> dict:
249
  def fail(run_dir: Path, events_path: Path, message: str, details: dict | None = None, status: str = "failed"):
250
  safe = safe_details(details)
251
  append_event(events_path, "failure", "failed", message, safe)
252
- write_json(run_dir / "state.json", {
 
 
 
 
253
  "run_id": os.environ.get("RUN_ID"),
254
- "kind": "universal_model_card_builder",
255
  "status": status,
256
  "message": message,
257
  "updated_at": now(),
258
  "details": safe,
259
- })
 
 
 
 
 
 
260
  report = f"""# Agentic Space Factory — model Article Reproduction Report
261
 
262
  Status: **{status}**
263
 
264
  {message}
265
 
 
 
266
  ```json
267
  {json.dumps(safe, indent=2, ensure_ascii=False)}
268
  ```
269
  """
270
  (run_dir / "report.md").write_text(report, encoding="utf-8")
 
271
  raise SystemExit(1)
272
 
273
 
@@ -437,7 +533,13 @@ def configure_pi(events_path: Path, model: str):
437
 
438
 
439
  def normalize_pi_model_name(value: str | None) -> str:
440
- return re.sub(r"[^a-z0-9]+", "", (value or "").lower())
 
 
 
 
 
 
441
 
442
 
443
  def extract_pi_models_from_text(text: str) -> list[str]:
@@ -447,11 +549,14 @@ def extract_pi_models_from_text(text: str) -> list[str]:
447
  patterns = [
448
  r"\bQwen/[A-Za-z0-9_.-]+",
449
  r"\bQwen(?:2(?:\.5)?|3)?[-_/A-Za-z0-9.]*Coder[-_/A-Za-z0-9.]*",
 
450
  r"\bKimi[-_/A-Za-z0-9.]+",
451
- r"\bMoonshotAI/[A-Za-z0-9_.-]+",
 
 
 
452
  r"\bClaude[-_/A-Za-z0-9.]+",
453
  r"\bGPT[-_/A-Za-z0-9.]+",
454
- r"\bDeepSeek[-_/A-Za-z0-9.]+",
455
  r"\b[Mm]odel(?:\\s+used|\\s+selected|\\s*:)?\\s*[:=]?\\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)",
456
  ]
457
  found: list[str] = []
@@ -539,6 +644,7 @@ def collect_pi_traces(run_dir: Path, events_path: Path):
539
  count = sync_pi_traces(run_dir, emit_event=False)
540
  write_agent_trace_record(run_dir, phase="initial_build", event="pi_sessions_collected", status="success", message="Synchronized Pi session traces into the run-level trace folder", data={"count": count}, artifacts=["traces/raw/agent_trace.jsonl", "traces/redacted/agent_trace.jsonl"])
541
  append_event(events_path, "traces", "success", "Collected Pi traces", {"count": count, "agent_trace": "traces/redacted/agent_trace.jsonl"})
 
542
  return count
543
 
544
 
@@ -943,6 +1049,10 @@ def safe_same_code_reupload(api, workspace: Path, target_space_id: str, token: s
943
 
944
 
945
  def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_path: Path):
 
 
 
 
946
  logs_dir = run_dir / "logs"
947
  logs_dir.mkdir(parents=True, exist_ok=True)
948
  written = []
@@ -976,6 +1086,7 @@ def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_p
976
  written.append({"file": filename, "source": "HfApi.get_space_logs", "error": msg[-1000:]})
977
 
978
  append_event(events_path, "space_logs", "success", "Collected best-effort Space logs", {"files": written})
 
979
  return written
980
 
981
 
@@ -1394,7 +1505,7 @@ sdk: gradio
1394
  app_file: app.py
1395
  python_version: "3.10"
1396
  suggested_hardware: {preferred_hardware or fallback_hardware or "cpu-basic"}
1397
- short_description: "Agent-built model demo"
1398
  ---
1399
 
1400
  # Generated Model Space — Agentic Space Factory
@@ -1456,33 +1567,62 @@ Deliverables:
1456
 
1457
 
1458
  def sanitize_readme_metadata(workspace: Path, events_path: Path):
 
 
 
 
 
 
 
1459
  readme_path = workspace / "README.md"
1460
  if not readme_path.exists():
1461
- return
1462
  text = readme_path.read_text(encoding="utf-8", errors="ignore")
1463
- if not text.startswith("---"):
1464
- return
1465
- parts = text.split("---", 2)
1466
- if len(parts) < 3:
1467
- return
1468
- _, frontmatter, body = parts
1469
  changed = False
1470
- sanitized_lines = []
1471
- for line in frontmatter.splitlines():
1472
- if line.strip().startswith("short_description:"):
1473
- value = "Generated model demo"
1474
- sanitized_lines.append(f"short_description: {value}")
1475
- changed = True
 
 
 
1476
  else:
1477
- sanitized_lines.append(line)
1478
- # If Pi added other unexpectedly long one-line metadata values, leave them alone:
1479
- # the known Hub validation blocker for this run was short_description > 60 chars.
1480
- if changed:
1481
- new_text = "---\n" + "\n".join(sanitized_lines).strip() + "\n---" + body
1482
- readme_path.write_text(new_text, encoding="utf-8")
1483
- append_event(events_path, "metadata_sanitize", "success", "Sanitized README metadata", {"short_description": "Generated model demo"})
1484
-
1485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1486
 
1487
  def normalize_requirements_for_modern_hub(workspace: Path, events_path: Path):
1488
  """Normalize only broad known-dangerous base dependencies before upload.
@@ -2576,7 +2716,7 @@ def main():
2576
  pi_model_resolution = {"requested_model": pi_model, "configured_model": pi_model, "effective_model": pi_model, "provider": "huggingface", "mismatch": False}
2577
 
2578
  append_event(events_path, "bootstrap", "started", "Universal model-card builder worker started", {"model_id": model_id, "target_space_id": target_space_id})
2579
- 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()})
2580
  if not token:
2581
  fail(run_dir, events_path, "HF_TOKEN is missing from Job secrets")
2582
  if not TARGET_RE.match(target_space_id):
@@ -2649,12 +2789,29 @@ def main():
2649
  selected_hardware = hardware_strategy.get("selected_hardware") or "default-cpu-or-existing"
2650
  hardware_attempts = list(hardware_strategy.get("attempts") or [])
2651
  requested_hardware_sequence = list(hardware_strategy.get("requested_sequence") or [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2652
 
2653
  # Upload after create. If create_repo(space_hardware=...) succeeded, the build
2654
  # starts directly on the requested hardware. If it fell back to CPU, the run
2655
  # remains valid but will be marked manual_hardware_required when inference
2656
  # signals indicate GPU is needed.
2657
  upload_workspace(api, workspace, target_space_id, token, run_dir, events_path)
 
2658
 
2659
  if selected_hardware == "default-cpu-or-existing":
2660
  append_event(events_path, "hardware", "warning", "Automatic hardware-at-creation failed; Space is on default CPU unless user changes it manually", {"attempts": hardware_attempts})
@@ -2716,6 +2873,7 @@ def main():
2716
  "status": inference_gate["status"],
2717
  "message": inference_gate["message"],
2718
  "model_id": model_id,
 
2719
  "target_space": target_space_id,
2720
  "target_space_url": f"https://huggingface.co/spaces/{target_space_id}",
2721
  "selected_hardware": selected_hardware,
@@ -2790,6 +2948,7 @@ The wrapper validated the live Space using HTTP `/health` first, with Gradio Cli
2790
  """
2791
  (run_dir / "report.md").write_text(report, encoding="utf-8")
2792
  append_event(events_path, "report_write", "success", "Wrote report.md")
 
2793
  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"]})
2794
  except SystemExit:
2795
  raise
@@ -3144,6 +3303,10 @@ def safe_same_code_reupload(api, workspace: Path, target_space_id: str, token: s
3144
 
3145
 
3146
  def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_path: Path):
 
 
 
 
3147
  logs_dir = run_dir / "logs"
3148
  logs_dir.mkdir(parents=True, exist_ok=True)
3149
  written = []
@@ -3177,6 +3340,7 @@ def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_p
3177
  written.append({"file": filename, "source": "HfApi.get_space_logs", "error": msg[-1000:]})
3178
 
3179
  append_event(events_path, "space_logs", "success", "Collected best-effort Space logs", {"files": written})
 
3180
  return written
3181
 
3182
 
 
88
  print(line, flush=True)
89
 
90
 
91
+ def _artifact_entry(run_dir: Path, rel_path: str, *, kind: str = "file") -> dict:
92
+ path = run_dir / rel_path
93
+ if kind == "folder":
94
+ present = path.exists() and any(child.is_file() for child in path.rglob("*"))
95
+ else:
96
+ present = path.exists() and path.is_file()
97
+ payload = {"path": rel_path, "kind": kind, "present": bool(present)}
98
+ if present and kind == "file":
99
+ try:
100
+ payload["size"] = path.stat().st_size
101
+ except Exception:
102
+ pass
103
+ return payload
104
+
105
+
106
+ def write_artifact_manifest(run_dir: Path, *, events_path: Path | None = None, reason: str = "snapshot") -> dict:
107
+ """Snapshot the artifacts that the worker actually wrote to the mounted bucket.
108
+
109
+ The web UI should prefer this manifest over guessed paths. It is written
110
+ during failures as well as success so a run that created a Space and then
111
+ crashed can still expose traces, logs and reports in the dock.
112
+ """
113
+ run_id = os.environ.get("RUN_ID", "")
114
+ bucket_source = os.environ.get("BUCKET_SOURCE", "")
115
+ artifacts = [
116
+ _artifact_entry(run_dir, "events.jsonl"),
117
+ _artifact_entry(run_dir, "state.json"),
118
+ _artifact_entry(run_dir, "report.md"),
119
+ _artifact_entry(run_dir, "model_analysis.json"),
120
+ _artifact_entry(run_dir, "hardware_strategy.json"),
121
+ _artifact_entry(run_dir, "hardware_attempts.json"),
122
+ _artifact_entry(run_dir, "inference_gate.json"),
123
+ _artifact_entry(run_dir, "space_runtime.json"),
124
+ _artifact_entry(run_dir, "tests/generation_smoke.json"),
125
+ _artifact_entry(run_dir, "tests/api_schema.json"),
126
+ _artifact_entry(run_dir, "generated/TECHNICAL_BLOCKERS.json"),
127
+ _artifact_entry(run_dir, "repair/INCIDENT_BRIEF.md"),
128
+ _artifact_entry(run_dir, "repair/DEPENDENCY_ERROR_BRIEF.md"),
129
+ _artifact_entry(run_dir, "repair/PI_DIAGNOSIS_GOAL.md"),
130
+ _artifact_entry(run_dir, "repair/REPAIR_DECISION.json"),
131
+ _artifact_entry(run_dir, "repair/REPAIR_BRIEF.md"),
132
+ _artifact_entry(run_dir, "repair/REPAIR_PLAN.md"),
133
+ _artifact_entry(run_dir, "repair/REPAIR_SUMMARY.md"),
134
+ _artifact_entry(run_dir, "repair/BLOCKAGE.json"),
135
+ _artifact_entry(run_dir, "logs/pi_live_output.txt"),
136
+ _artifact_entry(run_dir, "logs/pi_output.txt"),
137
+ _artifact_entry(run_dir, "logs/pi_diagnosis_output.txt"),
138
+ _artifact_entry(run_dir, "logs/pi_repair_output.txt"),
139
+ _artifact_entry(run_dir, "logs/space_logs_build.txt"),
140
+ _artifact_entry(run_dir, "logs/space_logs_runtime.txt"),
141
+ _artifact_entry(run_dir, "traces/raw", kind="folder"),
142
+ _artifact_entry(run_dir, "traces/redacted", kind="folder"),
143
+ _artifact_entry(run_dir, "generated", kind="folder"),
144
+ _artifact_entry(run_dir, "repair", kind="folder"),
145
+ _artifact_entry(run_dir, "logs", kind="folder"),
146
+ ]
147
+ payload = {
148
+ "run_id": run_id,
149
+ "bucket_source": bucket_source,
150
+ "updated_at": now(),
151
+ "reason": reason,
152
+ "artifacts": artifacts,
153
+ "present_paths": [a["path"] for a in artifacts if a.get("present")],
154
+ }
155
+ write_json(run_dir / "artifact_manifest.json", payload)
156
+ if events_path is not None:
157
+ try:
158
+ append_event(events_path, "artifact_manifest", "success", "Updated run artifact manifest", {"reason": reason, "present_count": len(payload["present_paths"])})
159
+ except Exception:
160
+ pass
161
+ return payload
162
+
163
+
164
+ def update_state(run_dir: Path, patch: dict) -> dict:
165
+ path = run_dir / "state.json"
166
+ current = load_json_if_exists(path) if path.exists() else {}
167
+ if not isinstance(current, dict):
168
+ current = {}
169
+ merged = {**current, **patch, "updated_at": now()}
170
+ write_json(path, merged)
171
+ return merged
172
+
173
+
174
  def redact_text(text: str | None) -> str:
175
  if not text:
176
  return ""
 
332
  def fail(run_dir: Path, events_path: Path, message: str, details: dict | None = None, status: str = "failed"):
333
  safe = safe_details(details)
334
  append_event(events_path, "failure", "failed", message, safe)
335
+ existing_state = load_json_if_exists(run_dir / "state.json") if (run_dir / "state.json").exists() else {}
336
+ if not isinstance(existing_state, dict):
337
+ existing_state = {}
338
+ failure_state = {
339
+ **existing_state,
340
  "run_id": os.environ.get("RUN_ID"),
341
+ "kind": existing_state.get("kind") or "universal_model_card_builder",
342
  "status": status,
343
  "message": message,
344
  "updated_at": now(),
345
  "details": safe,
346
+ }
347
+ # Preserve the target Space when the worker fails after repository creation.
348
+ target_space = failure_state.get("target_space") or os.environ.get("TARGET_SPACE_ID") or ""
349
+ if target_space:
350
+ failure_state["target_space"] = target_space
351
+ failure_state["target_space_url"] = f"https://huggingface.co/spaces/{target_space}"
352
+ write_json(run_dir / "state.json", failure_state)
353
  report = f"""# Agentic Space Factory — model Article Reproduction Report
354
 
355
  Status: **{status}**
356
 
357
  {message}
358
 
359
+ Target Space: {failure_state.get('target_space_url') or failure_state.get('target_space') or 'not created / unknown'}
360
+
361
  ```json
362
  {json.dumps(safe, indent=2, ensure_ascii=False)}
363
  ```
364
  """
365
  (run_dir / "report.md").write_text(report, encoding="utf-8")
366
+ write_artifact_manifest(run_dir, events_path=events_path, reason="failure")
367
  raise SystemExit(1)
368
 
369
 
 
533
 
534
 
535
  def normalize_pi_model_name(value: str | None) -> str:
536
+ raw = (value or "").strip().lower()
537
+ # Pi/provider traces may report either a full Hub id (moonshotai/Kimi-K2-...)
538
+ # or only the served model name (Kimi-K2-...). Compare on the model leaf so
539
+ # provider fallbacks are detected without false positives from missing owners.
540
+ if "/" in raw:
541
+ raw = raw.rsplit("/", 1)[-1]
542
+ return re.sub(r"[^a-z0-9]+", "", raw)
543
 
544
 
545
  def extract_pi_models_from_text(text: str) -> list[str]:
 
549
  patterns = [
550
  r"\bQwen/[A-Za-z0-9_.-]+",
551
  r"\bQwen(?:2(?:\.5)?|3)?[-_/A-Za-z0-9.]*Coder[-_/A-Za-z0-9.]*",
552
+ r"\bmoonshotai/[A-Za-z0-9_.-]+",
553
  r"\bKimi[-_/A-Za-z0-9.]+",
554
+ r"\bzai-org/[A-Za-z0-9_.-]+",
555
+ r"\bGLM[-_/A-Za-z0-9.]+",
556
+ r"\bdeepseek-ai/[A-Za-z0-9_.-]+",
557
+ r"\bDeepSeek[-_/A-Za-z0-9.]+",
558
  r"\bClaude[-_/A-Za-z0-9.]+",
559
  r"\bGPT[-_/A-Za-z0-9.]+",
 
560
  r"\b[Mm]odel(?:\\s+used|\\s+selected|\\s*:)?\\s*[:=]?\\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)",
561
  ]
562
  found: list[str] = []
 
644
  count = sync_pi_traces(run_dir, emit_event=False)
645
  write_agent_trace_record(run_dir, phase="initial_build", event="pi_sessions_collected", status="success", message="Synchronized Pi session traces into the run-level trace folder", data={"count": count}, artifacts=["traces/raw/agent_trace.jsonl", "traces/redacted/agent_trace.jsonl"])
646
  append_event(events_path, "traces", "success", "Collected Pi traces", {"count": count, "agent_trace": "traces/redacted/agent_trace.jsonl"})
647
+ write_artifact_manifest(run_dir, reason="pi_traces_collected")
648
  return count
649
 
650
 
 
1049
 
1050
 
1051
  def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_path: Path):
1052
+ # Import locally because main() imports HfApi inside its own scope after
1053
+ # installing dependencies. Recovery/log collection can run from helper
1054
+ # functions where that local name is otherwise unavailable.
1055
+ from huggingface_hub import HfApi
1056
  logs_dir = run_dir / "logs"
1057
  logs_dir.mkdir(parents=True, exist_ok=True)
1058
  written = []
 
1086
  written.append({"file": filename, "source": "HfApi.get_space_logs", "error": msg[-1000:]})
1087
 
1088
  append_event(events_path, "space_logs", "success", "Collected best-effort Space logs", {"files": written})
1089
+ write_artifact_manifest(run_dir, reason="space_logs_collected")
1090
  return written
1091
 
1092
 
 
1505
  app_file: app.py
1506
  python_version: "3.10"
1507
  suggested_hardware: {preferred_hardware or fallback_hardware or "cpu-basic"}
1508
+ short_description: "Generated model demo"
1509
  ---
1510
 
1511
  # Generated Model Space — Agentic Space Factory
 
1567
 
1568
 
1569
  def sanitize_readme_metadata(workspace: Path, events_path: Path):
1570
+ """Ensure the generated Space README always keeps valid HF metadata.
1571
+
1572
+ Pi is allowed to rewrite README.md, but Spaces require a valid YAML
1573
+ frontmatter block for sdk/app_file. If Pi drops it, the Space can fail with
1574
+ a Hub configuration error before the app even builds. Treat this as a
1575
+ factory invariant, not a Pi preference.
1576
+ """
1577
  readme_path = workspace / "README.md"
1578
  if not readme_path.exists():
1579
+ readme_path.write_text("# Generated Model Space\n", encoding="utf-8")
1580
  text = readme_path.read_text(encoding="utf-8", errors="ignore")
1581
+ body = text
1582
+ metadata = {}
 
 
 
 
1583
  changed = False
1584
+ if text.startswith("---"):
1585
+ parts = text.split("---", 2)
1586
+ if len(parts) >= 3:
1587
+ _, frontmatter, body = parts
1588
+ for line in frontmatter.splitlines():
1589
+ if ":" not in line:
1590
+ continue
1591
+ key, value = line.split(":", 1)
1592
+ metadata[key.strip()] = value.strip()
1593
  else:
1594
+ body = text
1595
+ changed = True
1596
+ else:
1597
+ changed = True
 
 
 
 
1598
 
1599
+ required = {
1600
+ "title": metadata.get("title") or "Generated Model Space",
1601
+ "sdk": "gradio",
1602
+ "app_file": "app.py",
1603
+ "python_version": metadata.get("python_version") or "3.10",
1604
+ }
1605
+ suggested = metadata.get("suggested_hardware")
1606
+ if suggested:
1607
+ required["suggested_hardware"] = suggested
1608
+ short = metadata.get("short_description") or "Generated model demo"
1609
+ if len(short.strip('"\'')) > 60:
1610
+ short = "Generated model demo"
1611
+ required["short_description"] = short
1612
+
1613
+ ordered_keys = ["title", "sdk", "app_file", "python_version", "suggested_hardware", "short_description"]
1614
+ lines = []
1615
+ for key in ordered_keys:
1616
+ if key in required and required[key]:
1617
+ value = str(required[key]).strip()
1618
+ if key in {"title", "short_description"} and not (value.startswith('"') or value.startswith("'")):
1619
+ value = json.dumps(value, ensure_ascii=False)
1620
+ lines.append(f"{key}: {value}")
1621
+ normalized_body = body.lstrip("\n") or "# Generated Model Space\n"
1622
+ new_text = "---\n" + "\n".join(lines) + "\n---\n\n" + normalized_body
1623
+ if new_text != text:
1624
+ readme_path.write_text(new_text, encoding="utf-8")
1625
+ append_event(events_path, "metadata_sanitize", "success", "Ensured README Space metadata", {"metadata_keys": [k for k in ordered_keys if k in required]})
1626
 
1627
  def normalize_requirements_for_modern_hub(workspace: Path, events_path: Path):
1628
  """Normalize only broad known-dangerous base dependencies before upload.
 
2716
  pi_model_resolution = {"requested_model": pi_model, "configured_model": pi_model, "effective_model": pi_model, "provider": "huggingface", "mismatch": False}
2717
 
2718
  append_event(events_path, "bootstrap", "started", "Universal model-card builder worker started", {"model_id": model_id, "target_space_id": target_space_id})
2719
+ write_json(state_path, {"run_id": run_id, "kind": "universal_model_card_builder", "status": "running", "message": "Attempting Universal model-card builder Space creation", "model_id": model_id, "pi_model": pi_model, "pi_model_resolution": pi_model_resolution, "target_space": target_space_id, "created_by": hf_username, "bucket_source": bucket_source, "created_at": now(), "updated_at": now()})
2720
  if not token:
2721
  fail(run_dir, events_path, "HF_TOKEN is missing from Job secrets")
2722
  if not TARGET_RE.match(target_space_id):
 
2789
  selected_hardware = hardware_strategy.get("selected_hardware") or "default-cpu-or-existing"
2790
  hardware_attempts = list(hardware_strategy.get("attempts") or [])
2791
  requested_hardware_sequence = list(hardware_strategy.get("requested_sequence") or [])
2792
+ update_state(run_dir, {
2793
+ "run_id": run_id,
2794
+ "kind": "universal_model_card_builder",
2795
+ "status": "running",
2796
+ "message": "Private Space created; uploading generated workspace",
2797
+ "model_id": model_id,
2798
+ "pi_model": pi_model,
2799
+ "target_space": target_space_id,
2800
+ "target_space_url": f"https://huggingface.co/spaces/{target_space_id}",
2801
+ "selected_hardware": selected_hardware,
2802
+ "hardware_attempts": hardware_attempts,
2803
+ "requested_hardware_sequence": requested_hardware_sequence,
2804
+ "created_by": hf_username,
2805
+ "bucket_source": bucket_source,
2806
+ })
2807
+ write_artifact_manifest(run_dir, reason="space_created")
2808
 
2809
  # Upload after create. If create_repo(space_hardware=...) succeeded, the build
2810
  # starts directly on the requested hardware. If it fell back to CPU, the run
2811
  # remains valid but will be marked manual_hardware_required when inference
2812
  # signals indicate GPU is needed.
2813
  upload_workspace(api, workspace, target_space_id, token, run_dir, events_path)
2814
+ write_artifact_manifest(run_dir, reason="workspace_uploaded")
2815
 
2816
  if selected_hardware == "default-cpu-or-existing":
2817
  append_event(events_path, "hardware", "warning", "Automatic hardware-at-creation failed; Space is on default CPU unless user changes it manually", {"attempts": hardware_attempts})
 
2873
  "status": inference_gate["status"],
2874
  "message": inference_gate["message"],
2875
  "model_id": model_id,
2876
+ "pi_model": pi_model,
2877
  "target_space": target_space_id,
2878
  "target_space_url": f"https://huggingface.co/spaces/{target_space_id}",
2879
  "selected_hardware": selected_hardware,
 
2948
  """
2949
  (run_dir / "report.md").write_text(report, encoding="utf-8")
2950
  append_event(events_path, "report_write", "success", "Wrote report.md")
2951
+ write_artifact_manifest(run_dir, events_path=events_path, reason="final")
2952
  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"]})
2953
  except SystemExit:
2954
  raise
 
3303
 
3304
 
3305
  def collect_space_logs(target_space_id: str, token: str, run_dir: Path, events_path: Path):
3306
+ # Import locally because main() imports HfApi inside its own scope after
3307
+ # installing dependencies. Recovery/log collection can run from helper
3308
+ # functions where that local name is otherwise unavailable.
3309
+ from huggingface_hub import HfApi
3310
  logs_dir = run_dir / "logs"
3311
  logs_dir.mkdir(parents=True, exist_ok=True)
3312
  written = []
 
3340
  written.append({"file": filename, "source": "HfApi.get_space_logs", "error": msg[-1000:]})
3341
 
3342
  append_event(events_path, "space_logs", "success", "Collected best-effort Space logs", {"files": written})
3343
+ write_artifact_manifest(run_dir, reason="space_logs_collected")
3344
  return written
3345
 
3346