amina-cli 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {amina_cli-0.4.2 → amina_cli-0.4.3}/PKG-INFO +1 -1
  2. {amina_cli-0.4.2 → amina_cli-0.4.3}/pyproject.toml +1 -1
  3. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/__init__.py +1 -1
  4. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/jobs_cmd.py +72 -7
  5. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/__init__.py +17 -4
  6. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/storage.py +28 -12
  7. {amina_cli-0.4.2 → amina_cli-0.4.3}/.gitignore +0 -0
  8. {amina_cli-0.4.2 → amina_cli-0.4.3}/LICENSE +0 -0
  9. {amina_cli-0.4.2 → amina_cli-0.4.3}/README.md +0 -0
  10. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/auth.py +0 -0
  11. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/client.py +0 -0
  12. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/__init__.py +0 -0
  13. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/auth_cmd.py +0 -0
  14. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/run_cmd.py +0 -0
  15. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/__init__.py +0 -0
  16. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/hydrophobicity.yaml +0 -0
  17. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/mmseqs2_cluster.yaml +0 -0
  18. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/residue_accessibility.yaml +0 -0
  19. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/rmsd.yaml +0 -0
  20. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/sasa.yaml +0 -0
  21. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/simple_rmsd.yaml +0 -0
  22. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/surface_charge.yaml +0 -0
  23. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/docs/usalign.yaml +0 -0
  24. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/hydrophobicity.py +0 -0
  25. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/mmseqs2_cluster.py +0 -0
  26. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/residue_accessibility.py +0 -0
  27. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/rmsd.py +0 -0
  28. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/sasa.py +0 -0
  29. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/simple_rmsd.py +0 -0
  30. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/surface_charge.py +0 -0
  31. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/analysis/usalign.py +0 -0
  32. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/__init__.py +0 -0
  33. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/docs/esm_if1.yaml +0 -0
  34. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/docs/protein_mc.yaml +0 -0
  35. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/docs/proteinmpnn.yaml +0 -0
  36. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/docs/rfdiffusion.yaml +0 -0
  37. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/esm_if1.py +0 -0
  38. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/protein_mc.py +0 -0
  39. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/proteinmpnn.py +0 -0
  40. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/design/rfdiffusion.py +0 -0
  41. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/display.py +0 -0
  42. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/doccard.py +0 -0
  43. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/__init__.py +0 -0
  44. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/boltz2.py +0 -0
  45. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/docs/boltz2.yaml +0 -0
  46. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/docs/esmfold.yaml +0 -0
  47. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/docs/openfold3.yaml +0 -0
  48. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/docs/protenix.yaml +0 -0
  49. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/esmfold.py +0 -0
  50. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/openfold3.py +0 -0
  51. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/folding/protenix.py +0 -0
  52. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/__init__.py +0 -0
  53. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/autodock_vina.py +0 -0
  54. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/diffdock.py +0 -0
  55. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/dockq.py +0 -0
  56. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/autodock_vina.yaml +0 -0
  57. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/diffdock.yaml +0 -0
  58. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/dockq.yaml +0 -0
  59. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/emngly.yaml +0 -0
  60. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/glycosylation_ensemble.yaml +0 -0
  61. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/interface_identifier.yaml +0 -0
  62. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/isoglyp.yaml +0 -0
  63. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/lmngly.yaml +0 -0
  64. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/p2rank.yaml +0 -0
  65. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/docs/pesto.yaml +0 -0
  66. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/emngly.py +0 -0
  67. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/glycosylation_ensemble.py +0 -0
  68. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/interface_identifier.py +0 -0
  69. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/isoglyp.py +0 -0
  70. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/lmngly.py +0 -0
  71. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/p2rank.py +0 -0
  72. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/interactions/pesto.py +0 -0
  73. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/properties/__init__.py +0 -0
  74. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/properties/aminosol.py +0 -0
  75. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/properties/docs/aminosol.yaml +0 -0
  76. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/properties/docs/esm1v.yaml +0 -0
  77. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/properties/docs/esm2_embedding.yaml +0 -0
  78. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/properties/esm1v.py +0 -0
  79. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/properties/esm2_embedding.py +0 -0
  80. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/__init__.py +0 -0
  81. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/activesite_verifier.py +0 -0
  82. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/chain_select.py +0 -0
  83. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/distance_calculator.py +0 -0
  84. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/activesite_verifier.yaml +0 -0
  85. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/chain_select.yaml +0 -0
  86. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/distance_calculator.yaml +0 -0
  87. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/maxit_convert.yaml +0 -0
  88. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/mol_size_calculator.yaml +0 -0
  89. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/obabel_convert.yaml +0 -0
  90. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/pdb_bfactor_overwrite.yaml +0 -0
  91. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/pdb_cleaner.yaml +0 -0
  92. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/pdb_quality_assessment.yaml +0 -0
  93. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/pdb_to_fasta.yaml +0 -0
  94. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/docs/protein_relaxer.yaml +0 -0
  95. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/maxit_convert.py +0 -0
  96. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/mol_size_calculator.py +0 -0
  97. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/obabel_convert.py +0 -0
  98. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/pdb_bfactor_overwrite.py +0 -0
  99. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/pdb_cleaner.py +0 -0
  100. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/pdb_quality_assessment.py +0 -0
  101. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/pdb_to_fasta.py +0 -0
  102. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools/utilities/protein_relaxer.py +0 -0
  103. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/commands/tools_cmd.py +0 -0
  104. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/main.py +0 -0
  105. {amina_cli-0.4.2 → amina_cli-0.4.3}/src/amina_cli/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amina-cli
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: CLI for AminoAnalytica protein engineering platform
5
5
  Project-URL: Homepage, https://aminoanalytica.com
6
6
  Project-URL: Documentation, https://docs.aminoanalytica.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amina-cli"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "CLI for AminoAnalytica protein engineering platform"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -9,4 +9,4 @@ Quick start:
9
9
  amina run esmfold --sequence "MKFLILLFNILCLFPVLAADNH"
10
10
  """
11
11
 
12
- __version__ = "0.4.2"
12
+ __version__ = "0.4.3"
@@ -775,7 +775,7 @@ def download(
775
775
  response_path.write_text(json.dumps(result, indent=2, default=str))
776
776
 
777
777
  try:
778
- downloaded = download_results(result, output)
778
+ downloaded, failed = download_results(result, output)
779
779
  if downloaded:
780
780
  console.print(f"[green]\u2713[/green] Downloaded {len(downloaded)} file(s) to {output}/")
781
781
  for path in downloaded:
@@ -797,8 +797,20 @@ def download(
797
797
 
798
798
  tool_metadata = get_tool(job_info.get("tool_name", ""))
799
799
  render_tool_output(result, tool_metadata)
800
- else:
800
+ elif not failed:
801
801
  console.print("[dim]No output files to download.[/dim]")
802
+ # Per-file failures are reported separately from total-failure (StorageError).
803
+ # A non-empty `failed` dict means a partial success \u2014 the caller (often an
804
+ # agent) needs to know which files are missing so it can rerun `amina jobs
805
+ # download` to mint fresh signed URLs and retry just the gaps.
806
+ if failed:
807
+ console.print(f"[yellow]Warning:[/yellow] {len(failed)} file(s) failed to download:")
808
+ for file_type, err in failed.items():
809
+ console.print(f" - {file_type}: {err}")
810
+ console.print(
811
+ "[dim]Re-run `amina jobs download` to mint fresh signed URLs and retry the missing files.[/dim]"
812
+ )
813
+ raise typer.Exit(1)
802
814
  except StorageError as e:
803
815
  # Show signed URLs as fallback
804
816
  signed_urls = result.get("signed_urls", {})
@@ -819,6 +831,40 @@ def download(
819
831
  # ═══════════════════════════════════════════════════════════════════════════════
820
832
 
821
833
 
834
+ def _missing_artifacts(response_path: Path, dir_path: Path) -> list[str]:
835
+ """Return artifact file_types in ``response.json`` whose local file is missing.
836
+
837
+ Used by ``reconcile`` to detect partial downloads: a response.json on disk
838
+ with declared ``output_files`` but no corresponding local files indicates
839
+ the original download partially failed (a stale signed URL, an HTTP 400,
840
+ a network blip). The dir needs a retry with fresh URLs, not a skip.
841
+
842
+ Returns an empty list when:
843
+ - response.json doesn't exist or is malformed (caller already handled)
844
+ - the result declared no ``output_files`` (e.g. data-only tools)
845
+ - every declared file is on disk
846
+
847
+ Returns a list of ``file_type`` keys (e.g. ``["pdb_filepath",
848
+ "csv_filepath"]``) for each missing artifact.
849
+ """
850
+ try:
851
+ result = json.loads(response_path.read_text())
852
+ except (OSError, json.JSONDecodeError):
853
+ # If we can't parse it, downstream code will re-resolve from scratch.
854
+ return []
855
+ output_files = result.get("output_files") or {}
856
+ if not isinstance(output_files, dict):
857
+ return []
858
+ missing: list[str] = []
859
+ for file_type, remote_path in output_files.items():
860
+ if not remote_path:
861
+ continue
862
+ local = dir_path / Path(remote_path).name
863
+ if not local.exists():
864
+ missing.append(file_type)
865
+ return missing
866
+
867
+
822
868
  def _find_submission_files(root: Path, recursive: bool) -> list[tuple[str, Path]]:
823
869
  """Walk ``root`` for ``submission.json`` files and extract job_id from each.
824
870
 
@@ -945,11 +991,23 @@ def reconcile(
945
991
  response_path = dir_path / "response.json"
946
992
  entry: dict = {"job_id": job_id, "dir": str(dir_path)}
947
993
 
994
+ # Idempotency check has two parts: response.json must exist AND every
995
+ # declared artifact must be on local disk. Checking only response.json
996
+ # leaves silent gaps when the original download partially failed
997
+ # (HTTP 400 on a single file, stale URL after >1h, etc.) — reconcile
998
+ # would forever mark such dirs as `already_done` despite missing PDB
999
+ # or TRB outputs.
948
1000
  if response_path.exists():
949
- summary["already_done"] += 1
950
- entry["action"] = "skipped_response_json_exists"
951
- per_job.append(entry)
952
- continue
1001
+ missing = _missing_artifacts(response_path, dir_path)
1002
+ if not missing:
1003
+ summary["already_done"] += 1
1004
+ entry["action"] = "skipped_response_json_exists"
1005
+ per_job.append(entry)
1006
+ continue
1007
+ # Fall through to re-resolve status. _resolve_job_status will
1008
+ # mint fresh signed URLs (any in the on-disk response.json are
1009
+ # likely expired) and we'll retry the download below.
1010
+ entry["missing_artifacts_before_retry"] = missing
953
1011
 
954
1012
  job_info = get_job_info(job_id)
955
1013
  if not job_info:
@@ -1012,8 +1070,15 @@ def reconcile(
1012
1070
  entry["action"] = "wrote_response_json"
1013
1071
  if download:
1014
1072
  try:
1015
- downloaded = download_results(result_payload, dir_path)
1073
+ downloaded, failed = download_results(result_payload, dir_path)
1016
1074
  entry["downloaded_files"] = [p.name for p in downloaded]
1075
+ # Per-file failures are partial successes; count and surface
1076
+ # them so the agent can detect missing artifacts without
1077
+ # post-hoc disk inspection. Idempotency check above will
1078
+ # also catch this on the next pass and retry the gaps.
1079
+ if failed:
1080
+ summary["download_failed"] += 1
1081
+ entry["download_failures"] = failed
1017
1082
  except StorageError as dl_err:
1018
1083
  summary["download_failed"] += 1
1019
1084
  entry["download_error"] = str(dl_err)
@@ -382,12 +382,15 @@ def run_tool_with_progress(
382
382
 
383
383
  progress.update(task, description="Downloading results...")
384
384
 
385
- # Download output files
386
- downloaded = []
385
+ # Download output files. download_results returns (downloaded, failed):
386
+ # per-file errors land in `failed` rather than aborting the loop, so a
387
+ # single stale signed URL no longer silently abandons the other files.
388
+ downloaded: list[Path] = []
389
+ failed: dict[str, str] = {}
387
390
  try:
388
- downloaded = download_results(result, output_dir)
391
+ downloaded, failed = download_results(result, output_dir)
389
392
  except StorageError as e:
390
- # If signed_urls are available, show them as fallback
393
+ # Total failure (e.g. no signed URLs and no Supabase credentials).
391
394
  signed_urls = result.get("signed_urls", {})
392
395
  if signed_urls:
393
396
  console.print("\n[yellow]Warning:[/yellow] Could not download files automatically.")
@@ -398,6 +401,16 @@ def run_tool_with_progress(
398
401
  else:
399
402
  console.print(f"\n[yellow]Warning:[/yellow] Download failed: {e}")
400
403
 
404
+ # Surface per-file failures so agents don't silently end up with
405
+ # response.json present but artifacts missing.
406
+ if failed:
407
+ console.print(f"\n[yellow]Warning:[/yellow] {len(failed)} file(s) failed to download:")
408
+ for file_type, err in failed.items():
409
+ console.print(f" - {file_type}: {err}")
410
+ console.print(
411
+ "[dim]Re-run `amina jobs download` to mint fresh signed URLs and retry the missing files.[/dim]"
412
+ )
413
+
401
414
  # Persist the structured response payload (success or failure) so agents
402
415
  # and scripts can read metrics/warnings/cost without re-parsing stdout.
403
416
  # Soft-fail on filesystem errors — the job has already run and been
@@ -136,12 +136,14 @@ def download_results(
136
136
  result: dict,
137
137
  output_dir: Optional[Path] = None,
138
138
  bucket: str = DEFAULT_BUCKET,
139
- ) -> list[Path]:
139
+ ) -> tuple[list[Path], dict[str, str]]:
140
140
  """
141
141
  Download all output files from a tool result.
142
142
 
143
143
  Prefers signed URLs (no credentials needed) over direct Supabase access.
144
- The API response includes 'signed_urls' dict when available.
144
+ Individual file failures are recorded but do not stop the loop — every
145
+ file gets its own attempt. This means a single bad signed URL no longer
146
+ aborts the whole download and silently abandons the rest.
145
147
 
146
148
  Args:
147
149
  result: Tool execution result with 'output_files' and optional 'signed_urls'
@@ -149,23 +151,31 @@ def download_results(
149
151
  bucket: Supabase storage bucket (only used as fallback)
150
152
 
151
153
  Returns:
152
- List of downloaded file paths
154
+ Tuple ``(downloaded, failed)`` where:
155
+ - ``downloaded`` is the list of locally-written file paths
156
+ - ``failed`` is a dict ``{file_type: error_message}`` for files
157
+ that could not be downloaded. An empty ``failed`` dict means
158
+ the download was complete; callers should treat any non-empty
159
+ ``failed`` as a partial success that must be retried.
153
160
 
154
161
  Raises:
155
- StorageError: If any download fails
162
+ StorageError: Only when there is no way to attempt any download at
163
+ all (e.g. no signed URLs AND no Supabase credentials). Per-file
164
+ failures are reported in the ``failed`` dict instead.
156
165
  """
157
166
  output_files = result.get("output_files", {})
158
167
  signed_urls = result.get("signed_urls", {})
159
168
 
160
169
  if not output_files and not signed_urls:
161
- return []
170
+ return [], {}
162
171
 
163
172
  if output_dir is None:
164
173
  output_dir = Path.cwd()
165
174
 
166
175
  output_dir.mkdir(parents=True, exist_ok=True)
167
176
 
168
- downloaded = []
177
+ downloaded: list[Path] = []
178
+ failed: dict[str, str] = {}
169
179
 
170
180
  # Prefer signed URLs (no credentials needed)
171
181
  if signed_urls:
@@ -182,10 +192,13 @@ def download_results(
182
192
  filename = f"{file_type.replace('_filepath', '')}.out"
183
193
 
184
194
  local_path = output_dir / filename
185
- download_from_signed_url(signed_url, local_path)
186
- downloaded.append(local_path)
195
+ try:
196
+ download_from_signed_url(signed_url, local_path)
197
+ downloaded.append(local_path)
198
+ except StorageError as e:
199
+ failed[file_type] = str(e)
187
200
 
188
- return downloaded
201
+ return downloaded, failed
189
202
 
190
203
  # No signed URLs available - direct Supabase access requires credentials
191
204
  # Check if we have credentials before attempting
@@ -203,10 +216,13 @@ def download_results(
203
216
 
204
217
  filename = Path(remote_path).name
205
218
  local_path = output_dir / filename
206
- download_file(remote_path, local_path, bucket)
207
- downloaded.append(local_path)
219
+ try:
220
+ download_file(remote_path, local_path, bucket)
221
+ downloaded.append(local_path)
222
+ except StorageError as e:
223
+ failed[file_type] = str(e)
208
224
 
209
- return downloaded
225
+ return downloaded, failed
210
226
 
211
227
 
212
228
  def upload_file(
File without changes
File without changes
File without changes