dh-cli 0.8.7__tar.gz → 0.9.0__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 (98) hide show
  1. {dh_cli-0.8.7 → dh_cli-0.9.0}/PKG-INFO +1 -1
  2. {dh_cli-0.8.7 → dh_cli-0.9.0}/pyproject.toml +1 -1
  3. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/__init__.py +2 -0
  4. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/finalize.py +36 -0
  5. dh_cli-0.9.0/src/dh_cli/batch/commands/rfd3.py +402 -0
  6. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/commands.py +124 -2
  7. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cost_command.py +30 -7
  8. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_key_command.py +26 -0
  9. dh_cli-0.9.0/tests/bedrock/test_rotate.py +280 -0
  10. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/conftest.py +1 -1
  11. dh_cli-0.9.0/tests/test_finalize_rfd3.py +130 -0
  12. dh_cli-0.9.0/tests/test_rfd3_command.py +456 -0
  13. {dh_cli-0.8.7 → dh_cli-0.9.0}/.gitignore +0 -0
  14. {dh_cli-0.8.7 → dh_cli-0.9.0}/LICENSE +0 -0
  15. {dh_cli-0.8.7 → dh_cli-0.9.0}/README.md +0 -0
  16. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/__init__.py +0 -0
  17. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/_identity.py +0 -0
  18. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/aws_batch.py +0 -0
  19. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/__init__.py +0 -0
  20. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/boltz.py +0 -0
  21. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/cancel.py +0 -0
  22. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/clean.py +0 -0
  23. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  24. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  25. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/local.py +0 -0
  26. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/logs.py +0 -0
  27. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/orca.py +0 -0
  28. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  29. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  30. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/retry.py +0 -0
  31. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/status.py +0 -0
  32. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/submit.py +0 -0
  33. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/train.py +0 -0
  34. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
  35. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/fasta_utils.py +0 -0
  36. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/h5_utils.py +0 -0
  37. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/job_id.py +0 -0
  38. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/manifest.py +0 -0
  39. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/batch/s3_transport.py +0 -0
  40. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/__init__.py +0 -0
  41. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/cost_report.py +0 -0
  42. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
  43. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/cloud_commands.py +0 -0
  44. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/codeartifact.py +0 -0
  45. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/__init__.py +0 -0
  46. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/api_client.py +0 -0
  47. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/auth.py +0 -0
  48. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  49. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/progress.py +0 -0
  50. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  51. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  52. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/github_commands.py +0 -0
  53. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/__init__.py +0 -0
  54. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/deploy.py +0 -0
  55. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/local.py +0 -0
  56. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/test.py +0 -0
  57. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/tf.py +0 -0
  58. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/hz/users.py +0 -0
  59. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/main.py +0 -0
  60. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/utility_commands.py +0 -0
  61. {dh_cli-0.8.7 → dh_cli-0.9.0}/src/dh_cli/warehouse.py +0 -0
  62. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/__init__.py +0 -0
  63. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_aws_batch_resources.py +0 -0
  64. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_image_override.py +0 -0
  65. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_submit_cpu_only.py +0 -0
  66. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_submit_image_validation.py +0 -0
  67. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/batch/test_submit_merge.py +0 -0
  68. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/conftest.py +0 -0
  69. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  70. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  71. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/C_plain.json +0 -0
  72. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  73. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
  74. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  75. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  76. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_build_report.py +0 -0
  77. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_classify_arn.py +0 -0
  78. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
  79. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cost_calc.py +0 -0
  80. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
  81. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_render_formats.py +0 -0
  82. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_resolve_base_model.py +0 -0
  83. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/bedrock/test_s3_walker.py +0 -0
  84. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/__init__.py +0 -0
  85. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  86. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_identity.py +0 -0
  87. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_login.py +0 -0
  88. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_login_error_paths.py +0 -0
  89. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_login_security.py +0 -0
  90. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_logout.py +0 -0
  91. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_rotate.py +0 -0
  92. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/github/test_status.py +0 -0
  93. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/hz/test_init.py +0 -0
  94. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/hz/test_suites.py +0 -0
  95. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/hz/test_users.py +0 -0
  96. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/test_cloud_gcp.py +0 -0
  97. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/test_finalize_boltz_tar.py +0 -0
  98. {dh_cli-0.8.7 → dh_cli-0.9.0}/tests/test_finalize_protmpnn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.8.7
3
+ Version: 0.9.0
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.8.7"
7
+ version = "0.9.0"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -22,6 +22,7 @@ from .commands.orca import orca
22
22
  from .commands.protmpnn import protmpnn
23
23
  from .commands.protmpnn_to_boltz import protmpnn_to_boltz
24
24
  from .commands.retry import retry
25
+ from .commands.rfd3 import rfd3
25
26
  from .commands.status import status
26
27
  from .commands.submit import submit
27
28
  from .commands.train import train
@@ -102,6 +103,7 @@ batch_cli.add_command(boltz)
102
103
  batch_cli.add_command(protmpnn)
103
104
  batch_cli.add_command(orca)
104
105
  batch_cli.add_command(protmpnn_to_boltz, name="protmpnn-to-boltz")
106
+ batch_cli.add_command(rfd3)
105
107
  batch_cli.add_command(train)
106
108
 
107
109
  __all__ = ["batch_cli"]
@@ -143,6 +143,8 @@ def finalize(job_id, output, force, keep_intermediates, full_output, skip_dedup,
143
143
  _finalize_boltz(output_dir, output_path, full_output=full_output)
144
144
  elif manifest.pipeline == "protmpnn":
145
145
  _finalize_protmpnn(output_dir, output_path)
146
+ elif manifest.pipeline == "rfd3":
147
+ _finalize_rfd3(output_dir, output_path)
146
148
  else:
147
149
  _finalize_generic(output_dir, output_path)
148
150
 
@@ -544,3 +546,37 @@ def _finalize_protmpnn(output_dir: Path, output_path: Path):
544
546
 
545
547
  click.echo(f"{num_variants} variants from {num_configs} config(s), top confidence: {top_conf}")
546
548
  click.echo(click.style(f"Results: {output_path / 'results.csv'}", fg="green"))
549
+
550
+
551
+ def _finalize_rfd3(output_dir: Path, output_path: Path):
552
+ """Collate completed RFD3 per-config backbone dirs into the final output.
553
+
554
+ The worker promotes each config's flat `.cif.gz` + `.json` design files
555
+ into `output/<stem>/` and drops a `output/rfd3_<stem>.done` marker only
556
+ when validation passed. Finalize copies each completed config's directory
557
+ into `output_path/<stem>/`, skipping configs without a `.done` marker and
558
+ the loose worker-index markers.
559
+ """
560
+ completed = []
561
+ for marker in sorted(output_dir.glob("rfd3_*.done")):
562
+ stem = marker.name[len("rfd3_") : -len(".done")]
563
+ if (output_dir / stem).is_dir():
564
+ completed.append(stem)
565
+
566
+ if not completed:
567
+ click.echo("No completed RFD3 configs found in output directory.", err=True)
568
+ raise SystemExit(1)
569
+
570
+ output_path.mkdir(parents=True, exist_ok=True)
571
+
572
+ copied_count = 0
573
+ for stem in completed:
574
+ dest = output_path / stem
575
+ if dest.exists():
576
+ click.echo(f" Skipping {stem} (already exists)")
577
+ continue
578
+ shutil.copytree(output_dir / stem, dest)
579
+ copied_count += 1
580
+
581
+ click.echo(f"Collated {copied_count} RFD3 config(s)")
582
+ click.echo(click.style(f"Results: {output_path}", fg="green"))
@@ -0,0 +1,402 @@
1
+ """RFD3 protein backbone design pipeline command."""
2
+
3
+ import math
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import click
10
+ import yaml
11
+
12
+ from ..aws_batch import BatchClient, BatchError, resolve_dependency
13
+ from ..job_id import generate_job_id, get_aws_username
14
+ from ..manifest import (
15
+ BATCH_JOBS_BASE,
16
+ BatchConfig,
17
+ InputConfig,
18
+ JobManifest,
19
+ JobStatus,
20
+ OutputConfig,
21
+ create_job_directory,
22
+ save_manifest,
23
+ )
24
+
25
+ DEFAULT_QUEUE = "a10g-1x-spot"
26
+ MAX_WORKERS = 50
27
+ FILES_PER_WORKER = 2
28
+ DEFAULT_JOB_DEFINITION = "dayhoff-rfd3"
29
+ DEFAULT_IMAGE_URI = "074735440724.dkr.ecr.us-east-1.amazonaws.com/dayhoff:rfd3-latest"
30
+ OFFLINE_ENV_VAR = "HF_HUB_OFFLINE"
31
+
32
+ CHECKPOINT_SUBTREE = "/primordial/.cache/foundry/checkpoints"
33
+ PRIMORDIAL_CHECKPOINT_DIR = Path(CHECKPOINT_SUBTREE)
34
+
35
+
36
+ @click.command()
37
+ @click.argument("input_dir", type=click.Path(exists=True))
38
+ @click.option(
39
+ "--workers",
40
+ default=None,
41
+ type=int,
42
+ help="Number of parallel workers [default: ~1 per 2 files]",
43
+ )
44
+ @click.option(
45
+ "--queue",
46
+ default=DEFAULT_QUEUE,
47
+ help=f"Batch queue [default: {DEFAULT_QUEUE}]",
48
+ )
49
+ @click.option("--dry-run", is_flag=True, help="Show plan without submitting")
50
+ @click.option(
51
+ "--local",
52
+ "run_local",
53
+ is_flag=True,
54
+ help="Force local execution via Docker",
55
+ )
56
+ @click.option(
57
+ "--remote",
58
+ "run_remote",
59
+ is_flag=True,
60
+ help="Force Batch submission (override auto-detect)",
61
+ )
62
+ @click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
63
+ @click.option("--after", "after", multiple=True, help="Job ID(s) to wait for before starting")
64
+ @click.option("--num-designs", default=1, type=int, help="Number of designs per config")
65
+ def rfd3(
66
+ input_dir,
67
+ workers,
68
+ queue,
69
+ dry_run,
70
+ run_local,
71
+ run_remote,
72
+ base_path,
73
+ after,
74
+ num_designs,
75
+ ):
76
+ """Design protein backbones with RFD3.
77
+
78
+ Processes a directory of YAML config files, each specifying design
79
+ parameters and an optional motif structure.
80
+
81
+ \b
82
+ Examples:
83
+ # Run on a GPU engine (auto-detects GPU, runs locally)
84
+ dh batch rfd3 input/
85
+
86
+ # Force remote Batch submission
87
+ dh batch rfd3 input/ --remote
88
+
89
+ # Preview what would run
90
+ dh batch rfd3 input/ --dry-run
91
+
92
+ # Run locally via Docker
93
+ dh batch rfd3 input/ --local
94
+ """
95
+ input_path = Path(input_dir).resolve()
96
+
97
+ if run_local:
98
+ _run_local_mode(input_path, base_path)
99
+ return
100
+
101
+ if not run_remote and not dry_run:
102
+ if _has_local_gpu():
103
+ click.echo("GPU detected — running locally (use --remote to override)")
104
+ _run_local_mode(input_path, base_path)
105
+ return
106
+
107
+ _submit_batch_job(input_path, workers, queue, dry_run, base_path, after, num_designs)
108
+
109
+
110
+ def _has_local_gpu() -> bool:
111
+ """Check if a local NVIDIA GPU is available."""
112
+ try:
113
+ result = subprocess.run(
114
+ ["nvidia-smi"],
115
+ capture_output=True,
116
+ timeout=5,
117
+ )
118
+ return result.returncode == 0
119
+ except (FileNotFoundError, subprocess.TimeoutExpired):
120
+ return False
121
+
122
+
123
+ def _has_nvidia_runtime() -> bool:
124
+ """Check if the NVIDIA container runtime is available to Docker."""
125
+ try:
126
+ result = subprocess.run(
127
+ ["docker", "info", "--format", "{{json .Runtimes}}"],
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=5,
131
+ )
132
+ return result.returncode == 0 and "nvidia" in result.stdout
133
+ except (FileNotFoundError, subprocess.TimeoutExpired):
134
+ return False
135
+
136
+
137
+ def _discover_configs(input_path: Path) -> list[Path]:
138
+ return sorted(input_path.glob("*.yaml"))
139
+
140
+
141
+ def _validate_motif_paths(yaml_path: Path, input_root: Path):
142
+ """Reject absolute, escaping, or missing motif paths referenced by a config."""
143
+ with open(yaml_path) as f:
144
+ data = yaml.safe_load(f)
145
+
146
+ if not isinstance(data, dict):
147
+ return
148
+
149
+ for entry in data.values():
150
+ if not isinstance(entry, dict) or "input" not in entry:
151
+ continue
152
+ motif = entry["input"]
153
+
154
+ if Path(motif).is_absolute():
155
+ raise click.ClickException(f"Motif path must be relative, got absolute: {motif}")
156
+
157
+ resolved = (input_root / motif).resolve()
158
+ if not resolved.is_relative_to(input_root):
159
+ raise click.ClickException(f"Motif path escapes input dir: {motif}")
160
+
161
+ if not resolved.exists():
162
+ raise click.ClickException(f"Motif file not found: {motif}")
163
+
164
+
165
+ def _validate_inputs(input_path: Path) -> list[Path]:
166
+ """Discover and validate configs; raise a clean CLI error on any violation."""
167
+ configs = _discover_configs(input_path)
168
+ if not configs:
169
+ raise click.ClickException(f"No YAML config files found in {input_path}")
170
+
171
+ for config in configs:
172
+ _validate_motif_paths(config, input_path)
173
+
174
+ return configs
175
+
176
+
177
+ def _referenced_motifs(config: Path) -> list[str]:
178
+ """Return the relative motif ``input:`` paths referenced by a config."""
179
+ with open(config) as f:
180
+ data = yaml.safe_load(f)
181
+
182
+ if not isinstance(data, dict):
183
+ return []
184
+
185
+ motifs = []
186
+ for entry in data.values():
187
+ if isinstance(entry, dict) and "input" in entry:
188
+ motifs.append(entry["input"])
189
+ return motifs
190
+
191
+
192
+ def _copy_inputs_to_job_dir(input_path: Path, job_dir: Path) -> int:
193
+ """Copy configs and their referenced motif files into the job directory.
194
+
195
+ Motifs are copied at their relative subpath (e.g. ``motifs/x.pdb``) so
196
+ RFD3 resolves the config's relative ``input:`` path from the copied dir.
197
+ """
198
+ dest = job_dir / "input"
199
+ dest.mkdir(parents=True, exist_ok=True)
200
+
201
+ count = 0
202
+ for yaml_file in sorted(input_path.glob("*.yaml")):
203
+ shutil.copy2(yaml_file, dest / yaml_file.name)
204
+ count += 1
205
+ for motif in _referenced_motifs(yaml_file):
206
+ src = input_path / motif
207
+ target = dest / motif
208
+ target.parent.mkdir(parents=True, exist_ok=True)
209
+ shutil.copy2(src, target)
210
+
211
+ return count
212
+
213
+
214
+ def _submit_batch_job(
215
+ input_path: Path,
216
+ workers: int | None,
217
+ queue: str,
218
+ dry_run: bool,
219
+ base_path: str,
220
+ after: tuple[str, ...],
221
+ num_designs: int,
222
+ ):
223
+ """Submit an RFD3 job to AWS Batch."""
224
+ click.echo(f"Scanning {input_path} for YAML files...")
225
+ configs = _validate_inputs(input_path)
226
+ num_files = len(configs)
227
+ click.echo(f"Found {num_files} config(s) to process")
228
+
229
+ if workers is None:
230
+ workers = max(1, min(math.ceil(num_files / FILES_PER_WORKER), MAX_WORKERS))
231
+ array_size = min(num_files, workers)
232
+
233
+ job_id = generate_job_id("rfd3")
234
+
235
+ click.echo()
236
+ click.echo(f"Job ID: {job_id}")
237
+ click.echo(f"Input: {input_path}")
238
+ click.echo(f"Configs: {num_files}")
239
+ click.echo(f"Workers: {array_size}")
240
+ click.echo(f"Num designs: {num_designs}")
241
+ click.echo(f"Queue: {DEFAULT_QUEUE}")
242
+ click.echo(f"Job definition: {DEFAULT_JOB_DEFINITION}")
243
+ click.echo(f"Image: {DEFAULT_IMAGE_URI}")
244
+
245
+ if dry_run:
246
+ click.echo()
247
+ click.echo(click.style("Dry run - job not submitted (REMOTE plan)", fg="yellow"))
248
+ return
249
+
250
+ if not click.confirm("\nSubmit job?", default=True):
251
+ click.echo("Cancelled.")
252
+ raise SystemExit(0)
253
+ click.echo()
254
+
255
+ client = BatchClient()
256
+
257
+ response = client.batch.describe_job_definitions(jobDefinitionName=DEFAULT_JOB_DEFINITION, status="ACTIVE")
258
+ if not response.get("jobDefinitions"):
259
+ raise click.ClickException("rfd3 job definition not yet deployed; apply Terraform in environments/dev first")
260
+
261
+ job_dir = create_job_directory(job_id, base_path)
262
+ click.echo(f"Created job directory: {job_dir}")
263
+
264
+ click.echo("Copying input files...")
265
+ copied = _copy_inputs_to_job_dir(input_path, job_dir)
266
+ click.echo(f"Copied {copied} YAML files")
267
+
268
+ manifest = JobManifest(
269
+ job_id=job_id,
270
+ user=job_id.split("-")[0],
271
+ pipeline="rfd3",
272
+ status=JobStatus.PENDING,
273
+ image_uri=DEFAULT_IMAGE_URI,
274
+ input=InputConfig(
275
+ source=str(input_path),
276
+ num_sequences=num_files,
277
+ num_chunks=array_size,
278
+ ),
279
+ batch=BatchConfig(
280
+ queue=queue,
281
+ job_definition=DEFAULT_JOB_DEFINITION,
282
+ array_size=array_size,
283
+ ),
284
+ output=OutputConfig(
285
+ destination=None,
286
+ finalized=False,
287
+ ),
288
+ depends_on=list(after) if after else None,
289
+ )
290
+
291
+ save_manifest(manifest, base_path)
292
+
293
+ try:
294
+ resolved = [resolve_dependency(jid, base_path) for jid in after]
295
+ depends_on = [{"jobId": aws_id} for aws_id in resolved if aws_id is not None] or None
296
+
297
+ environment = {
298
+ "JOB_DIR": str(job_dir),
299
+ "JOB_ID": job_id,
300
+ "BATCH_ARRAY_SIZE": str(array_size),
301
+ "BATCH_NUM_FILES": str(num_files),
302
+ "FOUNDRY_CHECKPOINT_DIRS": CHECKPOINT_SUBTREE,
303
+ OFFLINE_ENV_VAR: "1",
304
+ "NUM_DESIGNS": str(num_designs),
305
+ }
306
+
307
+ batch_job_id = client.submit_job(
308
+ job_name=job_id,
309
+ job_definition=DEFAULT_JOB_DEFINITION,
310
+ job_queue=queue,
311
+ array_size=array_size,
312
+ environment=environment,
313
+ timeout_seconds=12 * 3600,
314
+ retry_attempts=2,
315
+ depends_on=depends_on,
316
+ share_identifier=get_aws_username(),
317
+ )
318
+
319
+ manifest.status = JobStatus.SUBMITTED
320
+ manifest.batch.job_id = batch_job_id
321
+ save_manifest(manifest, base_path)
322
+
323
+ click.echo()
324
+ click.echo(click.style("Job submitted successfully!", fg="green"))
325
+ click.echo()
326
+ click.echo(f"AWS Batch Job ID: {batch_job_id}")
327
+ if depends_on:
328
+ click.echo(f"Waiting on: {', '.join(after)}")
329
+ click.echo()
330
+ click.echo("Next steps:")
331
+ click.echo(f" Check status: dh batch status {job_id}")
332
+ click.echo(f" View logs: dh batch logs {job_id}")
333
+ click.echo(f" Cancel: dh batch cancel {job_id}")
334
+ click.echo()
335
+ click.echo("After completion:")
336
+ click.echo(f" Finalize: dh batch finalize {job_id} --output ./results/")
337
+
338
+ except BatchError as e:
339
+ manifest.status = JobStatus.FAILED
340
+ manifest.error_message = str(e)
341
+ save_manifest(manifest, base_path)
342
+ click.echo(click.style(f"Failed to submit job: {e}", fg="red"), err=True)
343
+ raise SystemExit(1)
344
+
345
+
346
+ def _run_local_mode(input_path: Path, base_path: str = BATCH_JOBS_BASE):
347
+ """Run RFD3 locally in a Docker container after host-side fast-fail checks."""
348
+ machine = platform.machine()
349
+ if machine not in {"x86_64", "amd64"}:
350
+ raise click.ClickException(
351
+ f"Local RFD3 requires an x86_64 host (got {machine}); "
352
+ "no local image exists for this architecture. Use --remote instead."
353
+ )
354
+
355
+ if not PRIMORDIAL_CHECKPOINT_DIR.exists() or not any(PRIMORDIAL_CHECKPOINT_DIR.iterdir()):
356
+ raise click.ClickException(
357
+ f"Foundry checkpoints not found at {PRIMORDIAL_CHECKPOINT_DIR}; "
358
+ "this host cannot run RFD3 locally. Use --remote instead."
359
+ )
360
+
361
+ if not _has_nvidia_runtime():
362
+ raise click.ClickException(
363
+ "NVIDIA container runtime not available to Docker; cannot run RFD3 locally. Use --remote instead."
364
+ )
365
+
366
+ configs = _validate_inputs(input_path)
367
+
368
+ temp_job_dir = input_path / ".local_rfd3_job"
369
+ if temp_job_dir.exists():
370
+ shutil.rmtree(temp_job_dir)
371
+ (temp_job_dir / "output").mkdir(parents=True)
372
+ _copy_inputs_to_job_dir(input_path, temp_job_dir)
373
+
374
+ click.echo(f"Running RFD3 locally on {len(configs)} config(s)...")
375
+
376
+ cmd = [
377
+ "docker",
378
+ "run",
379
+ "--rm",
380
+ "--gpus",
381
+ "all",
382
+ "-v",
383
+ "/primordial:/primordial",
384
+ "-v",
385
+ f"{temp_job_dir}:{temp_job_dir}",
386
+ "-e",
387
+ f"JOB_DIR={temp_job_dir}",
388
+ "-e",
389
+ "AWS_BATCH_JOB_ARRAY_INDEX=0",
390
+ "-e",
391
+ "BATCH_ARRAY_SIZE=1",
392
+ "-e",
393
+ f"BATCH_NUM_FILES={len(configs)}",
394
+ "-e",
395
+ f"FOUNDRY_CHECKPOINT_DIRS={CHECKPOINT_SUBTREE}",
396
+ "-e",
397
+ f"{OFFLINE_ENV_VAR}=1",
398
+ DEFAULT_IMAGE_URI,
399
+ ]
400
+
401
+ click.echo(f"Running: {' '.join(cmd)}")
402
+ subprocess.run(cmd)
@@ -17,15 +17,30 @@ from __future__ import annotations
17
17
 
18
18
  import datetime as dt
19
19
  import json
20
+ import re
20
21
  import sys
21
- from typing import Optional
22
+ from typing import Optional, TypedDict
23
+ from uuid import uuid4
22
24
 
23
25
  import click
24
26
 
25
27
  from dh_cli._identity import HandleResolutionError, resolve_handle_from_session
28
+ from dh_cli.github_commands import _scrub_token
26
29
 
27
30
  from . import cost_report as cr
28
31
 
32
+
33
+ class BedrockKeyPayload(TypedDict):
34
+ """The JSON shape stored in `cursor-bedrock/<handle>`."""
35
+
36
+ access_key_id: str
37
+ secret_access_key: str
38
+ region: str
39
+
40
+
41
+ # Matches the admin SSO role within an assumed-role ARN.
42
+ _ADMIN_ROLE_RE = re.compile(r"AWSReservedSSO_DevAdminAccess_")
43
+
29
44
  # Hard-coded for the dev account. Kept here (not as user-facing flags)
30
45
  # because they never change in practice and making every dev remember
31
46
  # them is the whole reason this command exists.
@@ -124,9 +139,18 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
124
139
  resolved = handle or _resolve_handle()
125
140
  secret_id = f"{_SECRET_PREFIX}{resolved}"
126
141
 
142
+ sm = boto3.client("secretsmanager", region_name=region)
143
+ not_found = getattr(sm.exceptions, "ResourceNotFoundException", None)
144
+ if not (isinstance(not_found, type) and issubclass(not_found, BaseException)):
145
+ not_found = ()
127
146
  try:
128
- sm = boto3.client("secretsmanager", region_name=region)
129
147
  resp = sm.get_secret_value(SecretId=secret_id)
148
+ except not_found:
149
+ raise click.ClickException(
150
+ f"No key yet for `{resolved}` (secret `{secret_id}` has no current "
151
+ f"version). Ask an admin to run `dh bedrock rotate --handle {resolved}` "
152
+ "to mint your first key."
153
+ )
130
154
  except Exception as exc:
131
155
  raise click.ClickException(
132
156
  f"Could not read secret `{secret_id}`: {exc}\n"
@@ -151,6 +175,104 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
151
175
  click.echo(json.dumps(payload, indent=2))
152
176
 
153
177
 
178
+ # --------------------------------------------------------------------------
179
+ # `dh bedrock rotate`
180
+ # --------------------------------------------------------------------------
181
+
182
+
183
+ @bedrock.command("rotate")
184
+ @click.option(
185
+ "--handle",
186
+ "-u",
187
+ required=True,
188
+ help="Developer handle (e.g. 'dma'). Mints a key for `dev-cursor-<handle>`.",
189
+ )
190
+ @click.option(
191
+ "--region",
192
+ default=_DEFAULT_REGION,
193
+ show_default=True,
194
+ help="AWS region for the Secrets Manager secret.",
195
+ )
196
+ def bedrock_rotate(handle: str, region: str):
197
+ """Admin: mint a fresh Cursor Bedrock key for a developer.
198
+
199
+ Runs as `dev-admin`. Creates a new IAM access key for
200
+ `dev-cursor-<handle>`, writes it to `cursor-bedrock/<handle>`, then
201
+ deletes the prior key (delete-now).
202
+
203
+ \b
204
+ Examples:
205
+ dh bedrock rotate --handle dma
206
+ """
207
+ import boto3
208
+
209
+ user_name = f"dev-cursor-{handle}"
210
+ secret_id = f"{_SECRET_PREFIX}{handle}"
211
+
212
+ # Admin pre-check (defense-in-depth) — before any AWS mutation.
213
+ sts = boto3.Session().client("sts")
214
+ caller_arn = sts.get_caller_identity()["Arn"]
215
+ if not _ADMIN_ROLE_RE.search(caller_arn):
216
+ raise click.ClickException(
217
+ f"This command must be run as dev-admin (`awslogin dev-admin`). "
218
+ f"Current caller is not a DevAdminAccess role: {caller_arn}"
219
+ )
220
+
221
+ iam = boto3.client("iam")
222
+ sm = boto3.client("secretsmanager", region_name=region)
223
+
224
+ # Preflight — before create_access_key.
225
+ try:
226
+ resp = iam.list_access_keys(UserName=user_name)
227
+ except Exception as exc:
228
+ raise click.ClickException(
229
+ f"Could not list access keys for `{user_name}`: {exc}\n"
230
+ f"The IAM user may not exist yet — run `tfa` to provision it first."
231
+ )
232
+
233
+ prior_active_ids = [
234
+ meta["AccessKeyId"] for meta in resp.get("AccessKeyMetadata", []) if meta.get("Status") == "Active"
235
+ ]
236
+ if len(prior_active_ids) >= 2:
237
+ raise click.ClickException(
238
+ f"`{user_name}` already has 2 active keys (the IAM per-user limit). Delete one before rotating."
239
+ )
240
+
241
+ # Transaction: create → put → delete prior.
242
+ create_resp = iam.create_access_key(UserName=user_name)
243
+ new_akid = create_resp["AccessKey"]["AccessKeyId"]
244
+ new_secret = create_resp["AccessKey"]["SecretAccessKey"]
245
+
246
+ payload: BedrockKeyPayload = {
247
+ "access_key_id": new_akid,
248
+ "secret_access_key": new_secret,
249
+ "region": region,
250
+ }
251
+ try:
252
+ sm.put_secret_value(
253
+ SecretId=secret_id,
254
+ SecretString=json.dumps(payload),
255
+ ClientRequestToken=uuid4().hex,
256
+ )
257
+ except Exception as exc:
258
+ # Roll back the just-created key. Scrub the secret BEFORE building
259
+ # the message so the ClickException output can never leak it.
260
+ iam.delete_access_key(UserName=user_name, AccessKeyId=new_akid)
261
+ scrubbed = _scrub_token(str(exc), new_secret)
262
+ raise click.ClickException(
263
+ f"PutSecretValue failed for `{secret_id}`; rolled back new key "
264
+ f"{new_akid}. No prior key was deleted. Error: {scrubbed}"
265
+ )
266
+
267
+ # Only after put succeeds: delete prior active keys (not the new one).
268
+ for old_id in prior_active_ids:
269
+ if old_id == new_akid:
270
+ continue
271
+ iam.delete_access_key(UserName=user_name, AccessKeyId=old_id)
272
+
273
+ click.echo(f"Rotated `{secret_id}`. New access_key_id: {new_akid}")
274
+
275
+
154
276
  # --------------------------------------------------------------------------
155
277
  # `dh bedrock cost`
156
278
  # --------------------------------------------------------------------------