fffiloni commited on
Commit
c32592f
·
verified ·
1 Parent(s): 142ad34

Upload 14 files

Browse files
Files changed (4) hide show
  1. src/bucket.py +16 -2
  2. src/timeline_model.py +91 -12
  3. src/version.py +2 -2
  4. src/worker_payload.py +107 -8
src/bucket.py CHANGED
@@ -678,6 +678,7 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
678
  eval_publish_payload = bundle.get("eval_publish") if isinstance(bundle.get("eval_publish"), dict) else bundle.get("eval_publish_status") if isinstance(bundle.get("eval_publish_status"), dict) else {}
679
  eval_publish_present = bool(eval_publish_payload) or manifest_has("eval_publish_status.json") or "eval_publish_status.json" in file_paths or (bool(token) and _path_exists(f"{paths.root}/eval_publish_status.json", token=token))
680
  space_logs_index_present = manifest_has("logs/space_logs_index.json") or "logs/space_logs_index.json" in file_paths or (bool(token) and _path_exists(f"{paths.root}/logs/space_logs_index.json", token=token))
 
681
  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))
682
  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))
683
  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))
@@ -726,7 +727,7 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
726
  "icon": "◎",
727
  "present": eval_publish_present,
728
  "url": _bucket_file_url(bucket_source, run_id, "eval_publish_status.json"),
729
- "tone": "warn" if eval_publish_present and not eval_publish_payload.get("published") else "neutral",
730
  },
731
  {
732
  "id": "space_logs",
@@ -735,7 +736,16 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
735
  "icon": "▣",
736
  "present": space_logs_index_present,
737
  "url": _bucket_file_url(bucket_source, run_id, "logs/space_logs_index.json"),
738
- "tone": "neutral",
 
 
 
 
 
 
 
 
 
739
  },
740
  ]
741
  if repair_decision_present:
@@ -786,6 +796,9 @@ def _run_document_links(run_id: str, *, bucket_source: str, bundle: dict[str, An
786
  "tone": "warn",
787
  }
788
  )
 
 
 
789
  return docs
790
 
791
 
@@ -938,6 +951,7 @@ def read_run_bundle(run_id: str, *, bucket_source: str, token: str | None = None
938
  "space_runtime": _safe_read_json(f"{paths.root}/space_runtime.json", token=token),
939
  "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),
940
  "eval_publish_status": _safe_read_json(f"{paths.root}/eval_publish_status.json", token=token),
 
941
  "repair_decision": _safe_read_json(f"{paths.root}/repair/REPAIR_DECISION.json", token=token),
942
  "blockage": _safe_read_json(f"{paths.root}/repair/BLOCKAGE.json", token=token),
943
  "artifact_manifest": _safe_read_json(f"{paths.root}/artifact_manifest.json", token=token),
 
678
  eval_publish_payload = bundle.get("eval_publish") if isinstance(bundle.get("eval_publish"), dict) else bundle.get("eval_publish_status") if isinstance(bundle.get("eval_publish_status"), dict) else {}
679
  eval_publish_present = bool(eval_publish_payload) or manifest_has("eval_publish_status.json") or "eval_publish_status.json" in file_paths or (bool(token) and _path_exists(f"{paths.root}/eval_publish_status.json", token=token))
680
  space_logs_index_present = manifest_has("logs/space_logs_index.json") or "logs/space_logs_index.json" in file_paths or (bool(token) and _path_exists(f"{paths.root}/logs/space_logs_index.json", token=token))
681
+ build_error_present = manifest_has("build_error_observation.json") or "build_error_observation.json" in file_paths or (bool(token) and _path_exists(f"{paths.root}/build_error_observation.json", token=token))
682
  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))
683
  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))
684
  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))
 
727
  "icon": "◎",
728
  "present": eval_publish_present,
729
  "url": _bucket_file_url(bucket_source, run_id, "eval_publish_status.json"),
730
+ "tone": "success" if eval_publish_payload.get("published") else "pending" if eval_publish_present and str(eval_publish_payload.get("reason") or "") == "record_not_ready" else "warn" if eval_publish_present else "neutral",
731
  },
732
  {
733
  "id": "space_logs",
 
736
  "icon": "▣",
737
  "present": space_logs_index_present,
738
  "url": _bucket_file_url(bucket_source, run_id, "logs/space_logs_index.json"),
739
+ "tone": "warn" if "failed" in status or "error" in status else "neutral",
740
+ },
741
+ {
742
+ "id": "build_error",
743
+ "label": "Build error",
744
+ "subtitle": "Observed build failure",
745
+ "icon": "⛔",
746
+ "present": build_error_present,
747
+ "url": _bucket_file_url(bucket_source, run_id, "build_error_observation.json"),
748
+ "tone": "warn",
749
  },
750
  ]
751
  if repair_decision_present:
 
796
  "tone": "warn",
797
  }
798
  )
799
+ if "failed" in status or "error" in status:
800
+ priority = {"report": 0, "blockage": 1, "build_error": 2, "space_logs": 3, "repair_decision": 4, "repair": 5, "blockers": 6, "pi_raw_trace": 7, "pi_redacted_trace": 8, "smoke": 9, "eval_publish": 10}
801
+ docs.sort(key=lambda item: priority.get(str(item.get("id") or ""), 50))
802
  return docs
803
 
804
 
 
951
  "space_runtime": _safe_read_json(f"{paths.root}/space_runtime.json", token=token),
952
  "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),
953
  "eval_publish_status": _safe_read_json(f"{paths.root}/eval_publish_status.json", token=token),
954
+ "build_error_observation": _safe_read_json(f"{paths.root}/build_error_observation.json", token=token),
955
  "repair_decision": _safe_read_json(f"{paths.root}/repair/REPAIR_DECISION.json", token=token),
956
  "blockage": _safe_read_json(f"{paths.root}/repair/BLOCKAGE.json", token=token),
957
  "artifact_manifest": _safe_read_json(f"{paths.root}/artifact_manifest.json", token=token),
src/timeline_model.py CHANGED
@@ -144,6 +144,44 @@ def _eval_publish(bundle: dict[str, Any]) -> dict[str, Any]:
144
  return status if isinstance(status, dict) else {}
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  def _phase_status_from_events(bundle: dict[str, Any], phase: str) -> str:
148
  latest_by_step = _latest_events_by_step(bundle, phase)
149
  if not latest_by_step:
@@ -250,15 +288,19 @@ def _logs_warning(bundle: dict[str, Any]) -> dict[str, Any] | None:
250
  return None
251
 
252
 
253
- def _archive_warning(bundle: dict[str, Any]) -> dict[str, Any] | None:
254
  status = _eval_publish(bundle)
255
- if not status:
256
  return None
257
- if status.get("published") is True:
 
 
258
  return None
259
  if status.get("attempted"):
260
  return {"code": "eval_archive_not_published", "label": "Eval archive not published", "detail": str(status.get("reason") or "Backend archive copy did not complete.")}
261
- return {"code": "eval_archive_pending", "label": "Eval archive pending", "detail": "Backend archive copy has not been confirmed yet."}
 
 
262
 
263
 
264
  def _build_phase_details(bundle: dict[str, Any], phase: str) -> list[dict[str, Any]]:
@@ -277,6 +319,9 @@ def _build_phase_details(bundle: dict[str, Any], phase: str) -> list[dict[str, A
277
  elif smoke:
278
  reason = smoke.get("failure_type") or smoke.get("error") or "Generation was not verified"
279
  details.append({"label": f"Generation not verified: {reason}", "status": "warning"})
 
 
 
280
  recommendation = smoke.get("recommended_zero_gpu_duration_seconds") or (_gate(bundle).get("zero_gpu_duration_recommendation") or {}).get("recommended_zero_gpu_duration_seconds")
281
  if recommendation:
282
  details.append({"label": f"ZeroGPU duration recommendation: {recommendation}s", "status": "complete"})
@@ -288,7 +333,10 @@ def _build_phase_details(bundle: dict[str, Any], phase: str) -> list[dict[str, A
288
  if eval_status.get("archive_relative_path"):
289
  details.append({"label": str(eval_status.get("archive_relative_path")), "status": "info"})
290
  elif eval_status.get("attempted"):
291
- details.append({"label": f"Eval archive not published: {eval_status.get('reason') or 'unknown'}", "status": "warning"})
 
 
 
292
  else:
293
  details.append({"label": "Eval archive pending", "status": "pending"})
294
  elif phase == "recovery":
@@ -296,14 +344,22 @@ def _build_phase_details(bundle: dict[str, Any], phase: str) -> list[dict[str, A
296
  if not steps.intersection(PHASE_STEPS["recovery"]):
297
  details.append({"label": "Recovery not needed", "status": "skipped"})
298
  else:
 
 
 
 
 
299
  for label, candidates in [
300
  ("Diagnose", {"failure_diagnosis", "pi_diagnosis"}),
301
  ("Decide", {"repair_decision"}),
302
- ("Act", {"repair", "repair_patch", "repair_upload"}),
303
  ("Revalidate", {"repair_validation"}),
304
  ]:
305
- status = "complete" if steps.intersection(candidates) else "pending"
306
  details.append({"label": label, "status": status})
 
 
 
307
  else:
308
  latest = _latest_event_for_phase(bundle, phase)
309
  if latest:
@@ -321,6 +377,9 @@ def _phase_summary(bundle: dict[str, Any], phase: str, status: str) -> str:
321
  if warning:
322
  return warning["label"]
323
  if phase == "live_validation":
 
 
 
324
  signals = _signals(bundle)
325
  smoke = _smoke(bundle)
326
  if signals.get("generation_smoke_passed") is True or _lower(smoke.get("status")) == "success":
@@ -333,16 +392,24 @@ def _phase_summary(bundle: dict[str, Any], phase: str, status: str) -> str:
333
  steps = _event_steps(bundle)
334
  if not steps.intersection(PHASE_STEPS["recovery"]):
335
  return "Not needed"
 
 
336
  if any(_lower(e.get("step")) == "repair_validation" and _lower(e.get("status")) in SUCCESS_STATUSES for e in _events(bundle)):
337
  return "Repair revalidated"
338
- return "Recovery attempted"
 
 
 
 
 
 
339
  if phase == "archive":
340
  eval_status = _eval_publish(bundle)
341
  if eval_status.get("published") is True:
342
  return "Eval archive published"
343
- if eval_status.get("attempted"):
344
  return "Eval archive attempted"
345
- return "Final artifacts pending" if status in {"pending", "running"} else "Final artifacts written"
346
  if phase == "done":
347
  label, _ = _status_label(_verdict(bundle))
348
  return label
@@ -372,10 +439,20 @@ def _phase_status(bundle: dict[str, Any], phase: str, verdict: str) -> str:
372
  if phase == "recovery":
373
  if not steps.intersection(PHASE_STEPS["recovery"]):
374
  return "skipped" if terminal else "pending"
 
 
375
  if any(_lower(e.get("step")) == "repair_validation" and _lower(e.get("status")) in SUCCESS_STATUSES for e in _events(bundle)):
376
  return "complete"
 
 
 
 
 
377
 
378
  if phase == "live_validation":
 
 
 
379
  signals = _signals(bundle)
380
  smoke = _smoke(bundle)
381
  if signals.get("generation_smoke_passed") is True or _lower(smoke.get("status")) == "success":
@@ -389,10 +466,12 @@ def _phase_status(bundle: dict[str, Any], phase: str, verdict: str) -> str:
389
  if eval_status.get("published") is True:
390
  return "complete"
391
  if eval_status.get("attempted"):
 
 
392
  return "warning"
393
  if terminal:
394
  return "warning" if bundle.get("eval_record") else "pending"
395
- return _phase_status_from_events(bundle, phase)
396
 
397
  if phase == "agent" and _pi_model_warning(bundle):
398
  return "warning"
@@ -429,7 +508,7 @@ def build_run_timeline_model(bundle: dict[str, Any]) -> dict[str, Any]:
429
  terminal = visual_status in {"success", "warn", "error", "stopped"}
430
  percent = 100 if terminal else int(progress.get("progress") or 0)
431
 
432
- warnings = [w for w in (_pi_model_warning(bundle), _hardware_warning(bundle), _logs_warning(bundle), _archive_warning(bundle)) if w]
433
  phases: list[dict[str, Any]] = []
434
  for phase in PHASE_ORDER:
435
  status = _phase_status(bundle, phase, verdict)
 
144
  return status if isinstance(status, dict) else {}
145
 
146
 
147
+ def _build_error_observation(bundle: dict[str, Any]) -> dict[str, Any]:
148
+ observation = bundle.get("build_error_observation") or {}
149
+ return observation if isinstance(observation, dict) else {}
150
+
151
+
152
+ def _final_blocker_label(bundle: dict[str, Any]) -> str:
153
+ observation = _build_error_observation(bundle)
154
+ reason = _lower(observation.get("reason"))
155
+ tail = str(observation.get("tail") or observation.get("first_error") or "")
156
+ if reason or "pyenv install 3.1" in tail or "BUILD FAILED" in tail:
157
+ if "pyenv install 3.1" in tail or "python3.1" in tail.replace(" ", ""):
158
+ return "Build failed: invalid Python version requested pyenv install 3.1"
159
+ return "Build failed after repair"
160
+ blockage = bundle.get("blockage") or {}
161
+ if isinstance(blockage, dict):
162
+ status = str(blockage.get("status") or "").replace("_", " ").strip()
163
+ decision = blockage.get("decision") if isinstance(blockage.get("decision"), dict) else {}
164
+ reason_text = str(decision.get("reason") or blockage.get("reason") or "").strip()
165
+ if reason_text:
166
+ return reason_text[:220]
167
+ if status:
168
+ return status.title()
169
+ return ""
170
+
171
+
172
+ def _is_record_not_ready(status: dict[str, Any]) -> bool:
173
+ return _lower(status.get("reason")) == "record_not_ready"
174
+
175
+
176
+ def _has_terminal_event(bundle: dict[str, Any]) -> bool:
177
+ return any(_lower(event.get("step")) in {"done", "failure", "technical_blocker", "manual_hardware_required"} for event in _events(bundle))
178
+
179
+
180
+ def _latest_recovery_event(bundle: dict[str, Any]) -> dict[str, Any] | None:
181
+ events = _events_for_phase(bundle, "recovery")
182
+ return events[-1] if events else None
183
+
184
+
185
  def _phase_status_from_events(bundle: dict[str, Any], phase: str) -> str:
186
  latest_by_step = _latest_events_by_step(bundle, phase)
187
  if not latest_by_step:
 
288
  return None
289
 
290
 
291
+ def _archive_warning(bundle: dict[str, Any], *, terminal: bool) -> dict[str, Any] | None:
292
  status = _eval_publish(bundle)
293
+ if not status or status.get("published") is True:
294
  return None
295
+ # record_not_ready is the expected state while a run is still executing.
296
+ # It should not become a global warning until the run has actually ended.
297
+ if not terminal and _is_record_not_ready(status):
298
  return None
299
  if status.get("attempted"):
300
  return {"code": "eval_archive_not_published", "label": "Eval archive not published", "detail": str(status.get("reason") or "Backend archive copy did not complete.")}
301
+ if terminal:
302
+ return {"code": "eval_archive_pending", "label": "Eval archive pending", "detail": "Backend archive copy has not been confirmed yet."}
303
+ return None
304
 
305
 
306
  def _build_phase_details(bundle: dict[str, Any], phase: str) -> list[dict[str, Any]]:
 
319
  elif smoke:
320
  reason = smoke.get("failure_type") or smoke.get("error") or "Generation was not verified"
321
  details.append({"label": f"Generation not verified: {reason}", "status": "warning"})
322
+ blocker = _final_blocker_label(bundle)
323
+ if blocker:
324
+ details.append({"label": blocker, "status": "failed"})
325
  recommendation = smoke.get("recommended_zero_gpu_duration_seconds") or (_gate(bundle).get("zero_gpu_duration_recommendation") or {}).get("recommended_zero_gpu_duration_seconds")
326
  if recommendation:
327
  details.append({"label": f"ZeroGPU duration recommendation: {recommendation}s", "status": "complete"})
 
333
  if eval_status.get("archive_relative_path"):
334
  details.append({"label": str(eval_status.get("archive_relative_path")), "status": "info"})
335
  elif eval_status.get("attempted"):
336
+ if _is_record_not_ready(eval_status):
337
+ details.append({"label": "Eval archive pending until the run completes", "status": "pending"})
338
+ else:
339
+ details.append({"label": f"Eval archive not published: {eval_status.get('reason') or 'unknown'}", "status": "warning"})
340
  else:
341
  details.append({"label": "Eval archive pending", "status": "pending"})
342
  elif phase == "recovery":
 
344
  if not steps.intersection(PHASE_STEPS["recovery"]):
345
  details.append({"label": "Recovery not needed", "status": "skipped"})
346
  else:
347
+ failed_steps = {
348
+ _lower(e.get("step"))
349
+ for e in _events(bundle)
350
+ if _lower(e.get("step")) in PHASE_STEPS["recovery"] and _lower(e.get("status")) in FAILED_STATUSES
351
+ }
352
  for label, candidates in [
353
  ("Diagnose", {"failure_diagnosis", "pi_diagnosis"}),
354
  ("Decide", {"repair_decision"}),
355
+ ("Patch", {"repair", "repair_patch", "repair_upload"}),
356
  ("Revalidate", {"repair_validation"}),
357
  ]:
358
+ status = "failed" if failed_steps.intersection(candidates) else "complete" if steps.intersection(candidates) else "pending"
359
  details.append({"label": label, "status": status})
360
+ blocker = _final_blocker_label(bundle)
361
+ if blocker:
362
+ details.append({"label": blocker, "status": "failed"})
363
  else:
364
  latest = _latest_event_for_phase(bundle, phase)
365
  if latest:
 
377
  if warning:
378
  return warning["label"]
379
  if phase == "live_validation":
380
+ blocker = _final_blocker_label(bundle)
381
+ if blocker and _final_visual_status(_verdict(bundle)) == "error":
382
+ return "Live validation incomplete"
383
  signals = _signals(bundle)
384
  smoke = _smoke(bundle)
385
  if signals.get("generation_smoke_passed") is True or _lower(smoke.get("status")) == "success":
 
392
  steps = _event_steps(bundle)
393
  if not steps.intersection(PHASE_STEPS["recovery"]):
394
  return "Not needed"
395
+ if any(_lower(e.get("step")) == "repair_validation" and _lower(e.get("status")) in FAILED_STATUSES for e in _events(bundle)):
396
+ return "Patch attempted; final blocker remains"
397
  if any(_lower(e.get("step")) == "repair_validation" and _lower(e.get("status")) in SUCCESS_STATUSES for e in _events(bundle)):
398
  return "Repair revalidated"
399
+ latest = _latest_recovery_event(bundle) or {}
400
+ latest_step = _lower(latest.get("step"))
401
+ if latest_step in {"repair_validation"}:
402
+ return "Revalidating repair"
403
+ if latest_step in {"repair", "repair_plan", "repair_patch", "repair_upload"}:
404
+ return "Repairing"
405
+ return "Diagnosing repair"
406
  if phase == "archive":
407
  eval_status = _eval_publish(bundle)
408
  if eval_status.get("published") is True:
409
  return "Eval archive published"
410
+ if eval_status.get("attempted") and not _is_record_not_ready(eval_status):
411
  return "Eval archive attempted"
412
+ return "Archive pending" if status in {"pending", "running"} else "Final artifacts written"
413
  if phase == "done":
414
  label, _ = _status_label(_verdict(bundle))
415
  return label
 
439
  if phase == "recovery":
440
  if not steps.intersection(PHASE_STEPS["recovery"]):
441
  return "skipped" if terminal else "pending"
442
+ if any(_lower(e.get("step")) == "repair_validation" and _lower(e.get("status")) in FAILED_STATUSES for e in _events(bundle)):
443
+ return "failed"
444
  if any(_lower(e.get("step")) == "repair_validation" and _lower(e.get("status")) in SUCCESS_STATUSES for e in _events(bundle)):
445
  return "complete"
446
+ latest_recovery = _latest_recovery_event(bundle) or {}
447
+ latest_status = _lower(latest_recovery.get("status"))
448
+ if latest_status in FAILED_STATUSES:
449
+ return "failed"
450
+ return "warning" if terminal else "running"
451
 
452
  if phase == "live_validation":
453
+ blocker = _final_blocker_label(bundle)
454
+ if blocker and _final_visual_status(_verdict(bundle)) == "error":
455
+ return "Live validation incomplete"
456
  signals = _signals(bundle)
457
  smoke = _smoke(bundle)
458
  if signals.get("generation_smoke_passed") is True or _lower(smoke.get("status")) == "success":
 
466
  if eval_status.get("published") is True:
467
  return "complete"
468
  if eval_status.get("attempted"):
469
+ if not terminal and _is_record_not_ready(eval_status):
470
+ return "pending"
471
  return "warning"
472
  if terminal:
473
  return "warning" if bundle.get("eval_record") else "pending"
474
+ return "pending"
475
 
476
  if phase == "agent" and _pi_model_warning(bundle):
477
  return "warning"
 
508
  terminal = visual_status in {"success", "warn", "error", "stopped"}
509
  percent = 100 if terminal else int(progress.get("progress") or 0)
510
 
511
+ warnings = [w for w in (_pi_model_warning(bundle), _hardware_warning(bundle), _logs_warning(bundle), _archive_warning(bundle, terminal=terminal)) if w]
512
  phases: list[dict[str, Any]] = []
513
  for phase in PHASE_ORDER:
514
  status = _phase_status(bundle, phase, verdict)
src/version.py CHANGED
@@ -1,7 +1,7 @@
1
  from __future__ import annotations
2
 
3
- ASF_APP_VERSION = "v189.5"
4
- ASF_RELEASE_NAME = "Agentic Space Factory v189.5"
5
 
6
 
7
  def resolve_app_version(value: str | None = None) -> str:
 
1
  from __future__ import annotations
2
 
3
+ ASF_APP_VERSION = "v189.7"
4
+ ASF_RELEASE_NAME = "Agentic Space Factory v189.7"
5
 
6
 
7
  def resolve_app_version(value: str | None = None) -> str:
src/worker_payload.py CHANGED
@@ -52,14 +52,14 @@ INTERNAL_WORKSPACE_ARTIFACT_NAMES = {
52
 
53
 
54
  def internal_workspace_upload_ignore_patterns() -> list[str]:
55
- patterns = [".git/*", "node_modules/*", "__pycache__/*", "*.pyc"]
56
  for name in sorted(INTERNAL_WORKSPACE_ARTIFACT_NAMES):
57
  patterns.extend([name, f"**/{name}"])
58
  return patterns
59
 
60
 
61
  def internal_workspace_copy_ignore(_dir: str, names: list[str]) -> set[str]:
62
- ignored = {".git", "node_modules", "__pycache__"}
63
  ignored.update(name for name in names if name in INTERNAL_WORKSPACE_ARTIFACT_NAMES or name.endswith(".pyc"))
64
  return ignored
65
 
@@ -67,7 +67,7 @@ def internal_workspace_copy_ignore(_dir: str, names: list[str]) -> set[str]:
67
  def is_publishable_workspace_file(path: Path) -> bool:
68
  if path.name in INTERNAL_WORKSPACE_ARTIFACT_NAMES:
69
  return False
70
- if any(part in {".git", "node_modules", "__pycache__"} for part in path.parts):
71
  return False
72
  if path.suffix == ".pyc":
73
  return False
@@ -92,6 +92,43 @@ def append_event(path: Path, step: str, status: str, message: str, data: dict |
92
  print(line, flush=True)
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def _artifact_entry(run_dir: Path, rel_path: str, *, kind: str = "file") -> dict:
96
  path = run_dir / rel_path
97
  if kind == "folder":
@@ -2332,12 +2369,16 @@ def sanitize_readme_metadata(workspace: Path, events_path: Path):
2332
  else:
2333
  changed = True
2334
 
 
 
2335
  required = {
2336
  "title": metadata.get("title") or "Generated Model Space",
2337
  "sdk": "gradio",
2338
  "app_file": "app.py",
2339
- "python_version": metadata.get("python_version") or "3.10",
2340
  }
 
 
2341
  suggested = metadata.get("suggested_hardware")
2342
  if suggested:
2343
  required["suggested_hardware"] = suggested
@@ -2351,14 +2392,26 @@ def sanitize_readme_metadata(workspace: Path, events_path: Path):
2351
  for key in ordered_keys:
2352
  if key in required and required[key]:
2353
  value = str(required[key]).strip()
2354
- if key in {"title", "short_description"} and not (value.startswith('"') or value.startswith("'")):
2355
  value = json.dumps(value, ensure_ascii=False)
2356
  lines.append(f"{key}: {value}")
2357
  normalized_body = body.lstrip("\n") or "# Generated Model Space\n"
2358
  new_text = "---\n" + "\n".join(lines) + "\n---\n\n" + normalized_body
2359
  if new_text != text:
2360
  readme_path.write_text(new_text, encoding="utf-8")
2361
- append_event(events_path, "metadata_sanitize", "success", "Ensured README Space metadata", {"metadata_keys": [k for k in ordered_keys if k in required]})
 
 
 
 
 
 
 
 
 
 
 
 
2362
 
2363
  def normalize_requirements_for_modern_hub(workspace: Path, events_path: Path):
2364
  """Normalize only broad known-dangerous base dependencies before upload.
@@ -2426,6 +2479,12 @@ def normalize_requirements_for_modern_hub(workspace: Path, events_path: Path):
2426
  stable_policy_lines.append(policy[canonical])
2427
  changed = True
2428
 
 
 
 
 
 
 
2429
  new_lines = prefix_lines + stable_policy_lines + filtered
2430
  new = "\n".join(line for line in new_lines if line.strip()) + "\n"
2431
  if new != raw:
@@ -2440,7 +2499,10 @@ def normalize_requirements_for_modern_hub(workspace: Path, events_path: Path):
2440
  {
2441
  "huggingface_hub": policy["huggingface-hub"],
2442
  "transformers": policy["transformers"],
2443
- "reason": "Avoid uncontrolled Transformers 5.x while preserving model-specific dependency choices for Pi to repair from build logs.",
 
 
 
2444
  },
2445
  )
2446
 
@@ -2538,7 +2600,7 @@ def workspace_file_inventory(workspace: Path, max_files: int = 80) -> list[str]:
2538
  for path in sorted(workspace.rglob("*")):
2539
  if not path.is_file():
2540
  continue
2541
- if any(part in {".git", "node_modules", "__pycache__"} for part in path.parts):
2542
  continue
2543
  try:
2544
  files.append(str(path.relative_to(workspace)))
@@ -3740,6 +3802,43 @@ def append_event(path: Path, step: str, status: str, message: str, data: dict |
3740
  print(line, flush=True)
3741
 
3742
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3743
  def redact_text(text: str | None) -> str:
3744
  if not text:
3745
  return ""
 
52
 
53
 
54
  def internal_workspace_upload_ignore_patterns() -> list[str]:
55
+ patterns = [".git/*", ".cache/*", "**/.cache/*", "node_modules/*", "__pycache__/*", "*.pyc"]
56
  for name in sorted(INTERNAL_WORKSPACE_ARTIFACT_NAMES):
57
  patterns.extend([name, f"**/{name}"])
58
  return patterns
59
 
60
 
61
  def internal_workspace_copy_ignore(_dir: str, names: list[str]) -> set[str]:
62
+ ignored = {".git", ".cache", "node_modules", "__pycache__"}
63
  ignored.update(name for name in names if name in INTERNAL_WORKSPACE_ARTIFACT_NAMES or name.endswith(".pyc"))
64
  return ignored
65
 
 
67
  def is_publishable_workspace_file(path: Path) -> bool:
68
  if path.name in INTERNAL_WORKSPACE_ARTIFACT_NAMES:
69
  return False
70
+ if any(part in {".git", ".cache", "node_modules", "__pycache__"} for part in path.parts):
71
  return False
72
  if path.suffix == ".pyc":
73
  return False
 
92
  print(line, flush=True)
93
 
94
 
95
+ SAFE_SPACE_PYTHON_VERSIONS = {"3.10", "3.11", "3.12"}
96
+ DEFAULT_SPACE_PYTHON_VERSION = "3.10"
97
+
98
+
99
+ def normalize_space_python_version(value) -> tuple[str, bool, str]:
100
+ raw = str(value or "").strip().strip("\"'")
101
+ lowered = raw.lower().replace("python", "").strip()
102
+ match = re.search(r"(3)\.(\d+)(?:\.\d+)?", lowered)
103
+ if match:
104
+ normalized = f"{match.group(1)}.{match.group(2)}"
105
+ else:
106
+ normalized = lowered
107
+ if normalized in SAFE_SPACE_PYTHON_VERSIONS:
108
+ return normalized, normalized != raw, "allowed"
109
+ return DEFAULT_SPACE_PYTHON_VERSION, True, f"unsupported_or_ambiguous:{raw or 'missing'}"
110
+
111
+
112
+ def requirements_has_package(lines: list[str], package: str) -> bool:
113
+ wanted = package.lower().replace("_", "-")
114
+ for line in lines:
115
+ stripped = line.strip()
116
+ if not stripped or stripped.startswith("#") or stripped.startswith("-") or "://" in stripped:
117
+ continue
118
+ name = re.split(r"[<>=!~;\[]", stripped, 1)[0].strip().lower().replace("_", "-")
119
+ if name == wanted:
120
+ return True
121
+ return False
122
+
123
+
124
+ def workspace_app_imports_torch(workspace: Path) -> bool:
125
+ app_path = workspace / "app.py"
126
+ if not app_path.exists():
127
+ return False
128
+ text = app_path.read_text(encoding="utf-8", errors="ignore")
129
+ return bool(re.search(r"(?m)^\s*(import\s+torch\b|from\s+torch\b)", text))
130
+
131
+
132
  def _artifact_entry(run_dir: Path, rel_path: str, *, kind: str = "file") -> dict:
133
  path = run_dir / rel_path
134
  if kind == "folder":
 
2369
  else:
2370
  changed = True
2371
 
2372
+ raw_python_version = metadata.get("python_version") or DEFAULT_SPACE_PYTHON_VERSION
2373
+ python_version, python_version_changed, python_version_reason = normalize_space_python_version(raw_python_version)
2374
  required = {
2375
  "title": metadata.get("title") or "Generated Model Space",
2376
  "sdk": "gradio",
2377
  "app_file": "app.py",
2378
+ "python_version": python_version,
2379
  }
2380
+ if python_version_changed:
2381
+ changed = True
2382
  suggested = metadata.get("suggested_hardware")
2383
  if suggested:
2384
  required["suggested_hardware"] = suggested
 
2392
  for key in ordered_keys:
2393
  if key in required and required[key]:
2394
  value = str(required[key]).strip()
2395
+ if key in {"title", "short_description", "python_version"} and not (value.startswith('"') or value.startswith("'")):
2396
  value = json.dumps(value, ensure_ascii=False)
2397
  lines.append(f"{key}: {value}")
2398
  normalized_body = body.lstrip("\n") or "# Generated Model Space\n"
2399
  new_text = "---\n" + "\n".join(lines) + "\n---\n\n" + normalized_body
2400
  if new_text != text:
2401
  readme_path.write_text(new_text, encoding="utf-8")
2402
+ append_event(
2403
+ events_path,
2404
+ "metadata_sanitize",
2405
+ "success",
2406
+ "Ensured README Space metadata",
2407
+ {
2408
+ "metadata_keys": [k for k in ordered_keys if k in required],
2409
+ "python_version_before": str(raw_python_version).strip().strip("\"'"),
2410
+ "python_version_after": python_version,
2411
+ "python_version_normalized": bool(python_version_changed),
2412
+ "python_version_reason": python_version_reason,
2413
+ },
2414
+ )
2415
 
2416
  def normalize_requirements_for_modern_hub(workspace: Path, events_path: Path):
2417
  """Normalize only broad known-dangerous base dependencies before upload.
 
2479
  stable_policy_lines.append(policy[canonical])
2480
  changed = True
2481
 
2482
+ torch_added = False
2483
+ if workspace_app_imports_torch(workspace) and not requirements_has_package(filtered + stable_policy_lines, "torch"):
2484
+ stable_policy_lines.append("torch>=2.0.0")
2485
+ torch_added = True
2486
+ changed = True
2487
+
2488
  new_lines = prefix_lines + stable_policy_lines + filtered
2489
  new = "\n".join(line for line in new_lines if line.strip()) + "\n"
2490
  if new != raw:
 
2499
  {
2500
  "huggingface_hub": policy["huggingface-hub"],
2501
  "transformers": policy["transformers"],
2502
+ "reason": "Avoid uncontrolled Transformers 5.x while preserving model-specific dependency choices for Pi to repair from build logs; require torch when app.py imports torch.",
2503
+ "torch_added": torch_added,
2504
+ "torch_policy": "torch>=2.0.0",
2505
+ "torch_reason": "app_imports_torch" if torch_added else "not_needed_or_already_present",
2506
  },
2507
  )
2508
 
 
2600
  for path in sorted(workspace.rglob("*")):
2601
  if not path.is_file():
2602
  continue
2603
+ if any(part in {".git", ".cache", "node_modules", "__pycache__"} for part in path.parts):
2604
  continue
2605
  try:
2606
  files.append(str(path.relative_to(workspace)))
 
3802
  print(line, flush=True)
3803
 
3804
 
3805
+ SAFE_SPACE_PYTHON_VERSIONS = {"3.10", "3.11", "3.12"}
3806
+ DEFAULT_SPACE_PYTHON_VERSION = "3.10"
3807
+
3808
+
3809
+ def normalize_space_python_version(value) -> tuple[str, bool, str]:
3810
+ raw = str(value or "").strip().strip("\"'")
3811
+ lowered = raw.lower().replace("python", "").strip()
3812
+ match = re.search(r"(3)\.(\d+)(?:\.\d+)?", lowered)
3813
+ if match:
3814
+ normalized = f"{match.group(1)}.{match.group(2)}"
3815
+ else:
3816
+ normalized = lowered
3817
+ if normalized in SAFE_SPACE_PYTHON_VERSIONS:
3818
+ return normalized, normalized != raw, "allowed"
3819
+ return DEFAULT_SPACE_PYTHON_VERSION, True, f"unsupported_or_ambiguous:{raw or 'missing'}"
3820
+
3821
+
3822
+ def requirements_has_package(lines: list[str], package: str) -> bool:
3823
+ wanted = package.lower().replace("_", "-")
3824
+ for line in lines:
3825
+ stripped = line.strip()
3826
+ if not stripped or stripped.startswith("#") or stripped.startswith("-") or "://" in stripped:
3827
+ continue
3828
+ name = re.split(r"[<>=!~;\[]", stripped, 1)[0].strip().lower().replace("_", "-")
3829
+ if name == wanted:
3830
+ return True
3831
+ return False
3832
+
3833
+
3834
+ def workspace_app_imports_torch(workspace: Path) -> bool:
3835
+ app_path = workspace / "app.py"
3836
+ if not app_path.exists():
3837
+ return False
3838
+ text = app_path.read_text(encoding="utf-8", errors="ignore")
3839
+ return bool(re.search(r"(?m)^\s*(import\s+torch\b|from\s+torch\b)", text))
3840
+
3841
+
3842
  def redact_text(text: str | None) -> str:
3843
  if not text:
3844
  return ""