runops 2.3.0__tar.gz → 2.3.1__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 (136) hide show
  1. {runops-2.3.0 → runops-2.3.1}/PKG-INFO +1 -1
  2. {runops-2.3.0 → runops-2.3.1}/SPEC.md +4 -0
  3. {runops-2.3.0 → runops-2.3.1}/docs/reference.md +1 -1
  4. {runops-2.3.0 → runops-2.3.1}/pyproject.toml +1 -1
  5. {runops-2.3.0 → runops-2.3.1}/src/runops/__init__.py +1 -1
  6. {runops-2.3.0 → runops-2.3.1}/src/runops/cli.py +11 -13
  7. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/tidy.py +124 -50
  8. {runops-2.3.0 → runops-2.3.1}/tests/test_archive_series.py +19 -0
  9. {runops-2.3.0 → runops-2.3.1}/tests/test_mv_naming.py +40 -0
  10. {runops-2.3.0 → runops-2.3.1}/uv.lock +1 -1
  11. {runops-2.3.0 → runops-2.3.1}/.agents/skills/check/SKILL.md +0 -0
  12. {runops-2.3.0 → runops-2.3.1}/.agents/skills/improve-harness/SKILL.md +0 -0
  13. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/SKILL.md +0 -0
  14. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/agents/openai.yaml +0 -0
  15. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/examples/refactor-request.md +0 -0
  16. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/references/anti-patterns.md +0 -0
  17. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/references/api-compatibility.md +0 -0
  18. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/references/packaging-modernization.md +0 -0
  19. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/references/refactor-playbook.md +0 -0
  20. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/references/verification.md +0 -0
  21. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/scripts/api_surface_snapshot.py +0 -0
  22. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/scripts/import_smoke.py +0 -0
  23. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/scripts/inspect_python_package.py +0 -0
  24. {runops-2.3.0 → runops-2.3.1}/.agents/skills/python-package-refactor/scripts/refactor_quality_gate.py +0 -0
  25. {runops-2.3.0 → runops-2.3.1}/.agents/skills/release/SKILL.md +0 -0
  26. {runops-2.3.0 → runops-2.3.1}/.agents/skills/simulator-adapter/SKILL.md +0 -0
  27. {runops-2.3.0 → runops-2.3.1}/.agents/skills/spec-reviewer/SKILL.md +0 -0
  28. {runops-2.3.0 → runops-2.3.1}/.agents/skills/sync-docs/SKILL.md +0 -0
  29. {runops-2.3.0 → runops-2.3.1}/.claude/agents/spec-reviewer.md +0 -0
  30. {runops-2.3.0 → runops-2.3.1}/.claude/rules/architecture.md +0 -0
  31. {runops-2.3.0 → runops-2.3.1}/.claude/rules/dev-workflow.md +0 -0
  32. {runops-2.3.0 → runops-2.3.1}/.claude/rules/gotchas.md +0 -0
  33. {runops-2.3.0 → runops-2.3.1}/.claude/settings.json +0 -0
  34. {runops-2.3.0 → runops-2.3.1}/.claude/skills/check/SKILL.md +0 -0
  35. {runops-2.3.0 → runops-2.3.1}/.claude/skills/improve-harness/SKILL.md +0 -0
  36. {runops-2.3.0 → runops-2.3.1}/.claude/skills/python-package-refactor/SKILL.md +0 -0
  37. {runops-2.3.0 → runops-2.3.1}/.claude/skills/release/SKILL.md +0 -0
  38. {runops-2.3.0 → runops-2.3.1}/.claude/skills/simulator-adapter/SKILL.md +0 -0
  39. {runops-2.3.0 → runops-2.3.1}/.claude/skills/spec-reviewer/SKILL.md +0 -0
  40. {runops-2.3.0 → runops-2.3.1}/.claude/skills/sync-docs/SKILL.md +0 -0
  41. {runops-2.3.0 → runops-2.3.1}/.codex/README.md +0 -0
  42. {runops-2.3.0 → runops-2.3.1}/.codex/agents/spec-reviewer.toml +0 -0
  43. {runops-2.3.0 → runops-2.3.1}/.codex/config.toml +0 -0
  44. {runops-2.3.0 → runops-2.3.1}/.codex/rules/runops.rules +0 -0
  45. {runops-2.3.0 → runops-2.3.1}/.dockerignore +0 -0
  46. {runops-2.3.0 → runops-2.3.1}/.gitattributes +0 -0
  47. {runops-2.3.0 → runops-2.3.1}/.github/assets/conversation-dark.svg +0 -0
  48. {runops-2.3.0 → runops-2.3.1}/.github/assets/conversation-light.svg +0 -0
  49. {runops-2.3.0 → runops-2.3.1}/.github/assets/hero-dark.svg +0 -0
  50. {runops-2.3.0 → runops-2.3.1}/.github/assets/hero-light.svg +0 -0
  51. {runops-2.3.0 → runops-2.3.1}/.github/assets/make_readme_art.py +0 -0
  52. {runops-2.3.0 → runops-2.3.1}/.github/workflows/ci.yml +0 -0
  53. {runops-2.3.0 → runops-2.3.1}/.github/workflows/publish.yml +0 -0
  54. {runops-2.3.0 → runops-2.3.1}/.gitignore +0 -0
  55. {runops-2.3.0 → runops-2.3.1}/.harness/manifest.toml +0 -0
  56. {runops-2.3.0 → runops-2.3.1}/.pre-commit-config.yaml +0 -0
  57. {runops-2.3.0 → runops-2.3.1}/.vscode/settings.json +0 -0
  58. {runops-2.3.0 → runops-2.3.1}/AGENTS.md +0 -0
  59. {runops-2.3.0 → runops-2.3.1}/CLAUDE.md +0 -0
  60. {runops-2.3.0 → runops-2.3.1}/LICENSE +0 -0
  61. {runops-2.3.0 → runops-2.3.1}/README.md +0 -0
  62. {runops-2.3.0 → runops-2.3.1}/docs/adapter.md +0 -0
  63. {runops-2.3.0 → runops-2.3.1}/docs/guide.md +0 -0
  64. {runops-2.3.0 → runops-2.3.1}/src/runops/adapters/__init__.py +0 -0
  65. {runops-2.3.0 → runops-2.3.1}/src/runops/adapters/base.py +0 -0
  66. {runops-2.3.0 → runops-2.3.1}/src/runops/adapters/generic.py +0 -0
  67. {runops-2.3.0 → runops-2.3.1}/src/runops/adapters/registry.py +0 -0
  68. {runops-2.3.0 → runops-2.3.1}/src/runops/case.py +0 -0
  69. {runops-2.3.0 → runops-2.3.1}/src/runops/config.py +0 -0
  70. {runops-2.3.0 → runops-2.3.1}/src/runops/errors.py +0 -0
  71. {runops-2.3.0 → runops-2.3.1}/src/runops/harness.py +0 -0
  72. {runops-2.3.0 → runops-2.3.1}/src/runops/jobgen.py +0 -0
  73. {runops-2.3.0 → runops-2.3.1}/src/runops/launchers.py +0 -0
  74. {runops-2.3.0 → runops-2.3.1}/src/runops/legacy_adapters/__init__.py +0 -0
  75. {runops-2.3.0 → runops-2.3.1}/src/runops/legacy_adapters/beach.py +0 -0
  76. {runops-2.3.0 → runops-2.3.1}/src/runops/legacy_adapters/emses.py +0 -0
  77. {runops-2.3.0 → runops-2.3.1}/src/runops/manifest.py +0 -0
  78. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/__init__.py +0 -0
  79. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/common.py +0 -0
  80. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/create.py +0 -0
  81. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/listing.py +0 -0
  82. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/migrate.py +0 -0
  83. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/notes.py +0 -0
  84. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/setup.py +0 -0
  85. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/skills.py +0 -0
  86. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/submit.py +0 -0
  87. {runops-2.3.0 → runops-2.3.1}/src/runops/ops/sync.py +0 -0
  88. {runops-2.3.0 → runops-2.3.1}/src/runops/project.py +0 -0
  89. {runops-2.3.0 → runops-2.3.1}/src/runops/runtime.py +0 -0
  90. {runops-2.3.0 → runops-2.3.1}/src/runops/skills/runops-check-status/SKILL.md +0 -0
  91. {runops-2.3.0 → runops-2.3.1}/src/runops/skills/runops-create-run/SKILL.md +0 -0
  92. {runops-2.3.0 → runops-2.3.1}/src/runops/skills/runops-maintain/SKILL.md +0 -0
  93. {runops-2.3.0 → runops-2.3.1}/src/runops/skills/runops-reference/SKILL.md +0 -0
  94. {runops-2.3.0 → runops-2.3.1}/src/runops/skills/runops-tidy/SKILL.md +0 -0
  95. {runops-2.3.0 → runops-2.3.1}/src/runops/slurm.py +0 -0
  96. {runops-2.3.0 → runops-2.3.1}/src/runops/util.py +0 -0
  97. {runops-2.3.0 → runops-2.3.1}/tests/__init__.py +0 -0
  98. {runops-2.3.0 → runops-2.3.1}/tests/conftest.py +0 -0
  99. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/.codex/rules/a.md +0 -0
  100. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/.runops/test-runs/T20260701-0001/receipt.toml +0 -0
  101. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/.runops/work/beach-x-abc/bin/beach +0 -0
  102. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/AGENTS.md +0 -0
  103. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/CLAUDE.md +0 -0
  104. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/cases/beach/plane/beach.toml +0 -0
  105. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/cases/beach/plane/case.toml +0 -0
  106. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/cases/old/beach.toml +0 -0
  107. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/cases/old/case.toml +0 -0
  108. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/launchers.toml +0 -0
  109. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/research/CURRENT.md +0 -0
  110. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runops.toml +0 -0
  111. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runs/AGENTS.md +0 -0
  112. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runs/_archive/plane/R20260601-0001/manifest.toml +0 -0
  113. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/input/beach.toml +0 -0
  114. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/manifest.toml +0 -0
  115. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runs/plane/R20260702-0001/manifest.toml +0 -0
  116. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runs/scan/R20260701-0002/manifest.toml +0 -0
  117. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/runs/scan/survey.toml +0 -0
  118. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/simulators.toml +0 -0
  119. {runops-2.3.0 → runops-2.3.1}/tests/fixtures/v1-project/site.toml +0 -0
  120. {runops-2.3.0 → runops-2.3.1}/tests/golden/mpi_standard.sh +0 -0
  121. {runops-2.3.0 → runops-2.3.1}/tests/golden/openmp_rsc.sh +0 -0
  122. {runops-2.3.0 → runops-2.3.1}/tests/test_adapters.py +0 -0
  123. {runops-2.3.0 → runops-2.3.1}/tests/test_build.py +0 -0
  124. {runops-2.3.0 → runops-2.3.1}/tests/test_dev_harness.py +0 -0
  125. {runops-2.3.0 → runops-2.3.1}/tests/test_jobgen.py +0 -0
  126. {runops-2.3.0 → runops-2.3.1}/tests/test_legacy_adapters.py +0 -0
  127. {runops-2.3.0 → runops-2.3.1}/tests/test_lifecycle.py +0 -0
  128. {runops-2.3.0 → runops-2.3.1}/tests/test_manifest_config.py +0 -0
  129. {runops-2.3.0 → runops-2.3.1}/tests/test_migrate.py +0 -0
  130. {runops-2.3.0 → runops-2.3.1}/tests/test_project_scope.py +0 -0
  131. {runops-2.3.0 → runops-2.3.1}/tests/test_safety.py +0 -0
  132. {runops-2.3.0 → runops-2.3.1}/tests/test_scratch_tidy.py +0 -0
  133. {runops-2.3.0 → runops-2.3.1}/tests/test_setup_misc.py +0 -0
  134. {runops-2.3.0 → runops-2.3.1}/tests/test_skills.py +0 -0
  135. {runops-2.3.0 → runops-2.3.1}/tests/test_sweep.py +0 -0
  136. {runops-2.3.0 → runops-2.3.1}/tests/test_sync_evidence.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runops
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: Run ledger and Slurm job submission for HPC simulation projects
5
5
  Project-URL: Homepage, https://github.com/Nkzono99/runops
6
6
  Project-URL: Documentation, https://github.com/Nkzono99/runops/blob/main/docs/reference.md
@@ -573,6 +573,9 @@ created ──submit──▶ submitted ──(slurm)──▶ running ──▶
573
573
  - 対象は `created`、`completed`、`failed`、`cancelled`。`submitted` / `running` は拒否する
574
574
  (Slurm が run directory の絶対 path を持つ)。`archived` は `restore` を使う。
575
575
  - 既存 path は上書きしない(`move_noreplace`)。`created` の run は job.sh を作り直す。
576
+ - 複数 run は `run.lock` を 1 回取り、全対象の状態と移動先を検査してから移す。1 件でも拒否されれば
577
+ どれも動かさない。移動中に filesystem の error が起きたら止まり、移し終えた run id を error に示す
578
+ (exit code 1)。`archive RUN...` も同じ手順で移す。
576
579
  - 手で `mv` しても run は id で見つかるが、`run.label` と `[path].run_dir` が古いまま残る。
577
580
 
578
581
  ---
@@ -967,4 +970,5 @@ v2 の文書は次の 4 ファイルだけにする。
967
970
  | 操作範囲は起動 project(2026-10-21) | AGENTS.md block と skill に「対象は起動した project の run と job id だけ。アカウント全体の squeue / sacct は診断に見るだけ。自分で別 directory に移らず、生の `sbatch` / `scancel` を使わない」を書く。CLI は別 project の path を拒否する | 範囲を言わない「再投入して」で、エージェントが別 project や runops 管理外のジョブまで再投入した。cwd で project を決める既存の model に、path 引数の抜け道(`submit --all /other/runs` など)があった。他 session が修正のために止めた run を見分けられるよう、`cancel --why` を note に残し、`retry` はその理由を示して確認を取る(#112) |
968
971
  | 置き換えた系列の整理(2026-10-21) | `archive` が DIR(系列)を受け、`--superseded-by` で置き換え先を manifest に記録する。新しい command と tidy の種別は足さない。skill は新系列を作ったら旧系列の要否を確かめ、不要なら archive して現用系列・archive 先・件数を報告する | 修正版を作った後に旧系列が作業領域に残り、毎回ユーザーが整理を頼んでいた。`parent_run` だけでは比較用の派生と置き換えを区別できないので、置き換えは明示操作で記録する。旧 `archive` は 1 件ずつ検査していて、途中の running run で一部だけ移動していた(#113) |
969
972
  | 生成ハーネスの書き方(2026-10-21) | AGENTS.md の Rules を「作業の進め方」5 項目に絞り、各項目を「何をするか」と短い理由で書く。skill は手順に専念し、AGENTS.md の方針(manifest を手で編集しない等)を繰り返さない | #112・#113 で規則が増え、禁止形(never / do not)が並んでいた。現行の Claude の prompting 指針は、しないことより何をするかを、理由と共に書くことを勧め、強い禁止語は過剰反応を招くとしている |
973
+ | 複数 run の mv / archive(2026-10-22) | 1 回の `run.lock` の下で全対象を読み直して検査し、通ってから移す。途中の filesystem error では移し終えた run id を示して止まる | `mv A B C --dest X` は 1 件ずつ検査して移していたので、途中の submitted run で一部だけ移動していた。archive は表示の後で lock を取り直していて、その間に再投入された run で同じことが起き得た |
970
974
  | 使い方ガイド | `docs/guide.md` を追加し、文書を 4 ファイルにする(§13.3) | README の依頼例の表と command 一覧の間を埋める、場面ごとの流れ(case と run の分け方、smoke から本計算、状態の決まり方、整理)の置き場所が無かった |
@@ -162,7 +162,7 @@ run id は削除後も再利用しません。`--dest` に既存 run の中は
162
162
  | `runo list [PATH] [--all] [--since 30d] [--case] [--sweep] [--status] [--tag] [--scratch] [--archived] [--json]` | run 一覧 |
163
163
  | `runo note RUN [TEXT]` / `runo pin RUN [--off] [--reason]` | ノート追記・表示 / pin |
164
164
  | `runo summarize RUN... [--force]` | adapter の要約を `analysis/summary.json` に書く |
165
- | `runo mv RUN... [--dest DIR] [--label L] [--why TEXT]` | `runs/` 配下で改名・移動。id、入力、sweep、ノートは保つ。submitted / running と archived は不可 |
165
+ | `runo mv RUN... [--dest DIR] [--label L] [--why TEXT]` | `runs/` 配下で改名・移動。id、入力、sweep、ノートは保つ。submitted / running と archived は不可。全対象を検査してから移し、1 件でも不可なら何も動かさない |
166
166
  | `runo archive RUN\|DIR... [--superseded-by RUN\|DIR --why TEXT]` / `runo archive --older-than 90d` / `runo restore RUN` | `runs/_archive/` へ移動 / 戻す。移動前に全対象を検査し、指定した RUN が終了していなければ何も動かさない。DIR は配下の run(系列)で、created / submitted / running と pinned を除いて表示する。`--superseded-by` は置き換え先(その run から `--from` で派生した run、無ければ指定した全 run)を `superseded_by` に記録し、`--why` をノートに残す |
167
167
  | `runo delete RUN... [-y]` | created・failed・cancelled・scratch の run を削除 |
168
168
  | `runo tidy [--apply] [--only KIND] [--older-than AGE] [--purge-work RUN...]` | 整理候補の表示と実行 |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runops"
7
- version = "2.3.0"
7
+ version = "2.3.1"
8
8
  description = "Run ledger and Slurm job submission for HPC simulation projects"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """runops: run ledger and Slurm job submission for HPC simulation projects."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.3.1"
@@ -796,7 +796,7 @@ def archive(
796
796
  """Move terminal runs to runs/_archive/ (same relative path)."""
797
797
  from datetime import datetime
798
798
 
799
- from runops.ops.tidy import ArchivePlan, archive_run, plan_archive
799
+ from runops.ops.tidy import ArchivePlan, archive_runs, plan_archive
800
800
 
801
801
  project = _project()
802
802
  if superseded_by and not why:
@@ -827,11 +827,11 @@ def archive(
827
827
  line += f" -> {', '.join(plan.superseded_by[m.id])}"
828
828
  out.print(line)
829
829
  _confirm(f"Archive {len(plan.targets)} runs?", yes)
830
- for m in plan.targets:
831
- dst = archive_run(
832
- project, m, superseded_by=plan.superseded_by.get(m.id), why=why, by=_actor()
833
- )
834
- out.print(f"archived {m.id} -> {project.rel(dst)}")
830
+ archived = archive_runs(
831
+ project, plan.targets, superseded_by=plan.superseded_by, why=why, by=_actor()
832
+ )
833
+ for run_id, dst in archived:
834
+ out.print(f"archived {run_id} -> {project.rel(dst)}")
835
835
 
836
836
 
837
837
  @app.command("mv")
@@ -844,7 +844,7 @@ def mv_cmd(
844
844
  why: str = typer.Option("", "--why", help="Reason, recorded in the run's notes."),
845
845
  ) -> None:
846
846
  """Rename or move runs under runs/, keeping their ids, inputs and history."""
847
- from runops.ops.tidy import move_run
847
+ from runops.ops.tidy import move_runs
848
848
 
849
849
  if not dest and label is None:
850
850
  raise UsageError("give --dest and/or --label")
@@ -852,12 +852,10 @@ def mv_cmd(
852
852
  targets = _resolve(project, runs)
853
853
  if label is not None and len(targets) > 1:
854
854
  raise UsageError("--label applies to one RUN; use --dest to move several")
855
- warnings: list[str] = []
856
- for m in targets:
857
- result = move_run(project, m, dest=dest, label=label, why=why, by=_actor())
858
- warnings += result.warnings
859
- out.print(f"moved {m.id}: {project.rel(result.src)} -> {project.rel(result.dst)}")
860
- _warn(warnings)
855
+ results = move_runs(project, targets, dest=dest, label=label, why=why, by=_actor())
856
+ for r in results:
857
+ out.print(f"moved {r.run_id}: {project.rel(r.src)} -> {project.rel(r.dst)}")
858
+ _warn([w for r in results for w in r.warnings])
861
859
 
862
860
 
863
861
  @app.command()
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
 
11
11
  from runops.adapters.registry import optional
12
12
  from runops.case import slugify
13
- from runops.errors import RunopsError, UsageError
13
+ from runops.errors import RunError, RunopsError, UsageError
14
14
  from runops.manifest import ACTIVE_STATES, MANIFEST_FILE, Manifest, RunState
15
15
  from runops.ops.common import inside, resolve_dest, run_sim_context
16
16
  from runops.ops.submit import write_job_for
@@ -206,12 +206,37 @@ def archive_run(
206
206
  """Move a terminal run to ``runs/_archive/``; record replacements and *why* if given."""
207
207
  with project.run_lock():
208
208
  manifest.reload()
209
- return _archive_locked(project, manifest, superseded_by or [], why, by)
209
+ dst = _archive_dst(project, manifest)
210
+ return _archive_locked(project, manifest, dst, superseded_by or [], why, by)
210
211
 
211
212
 
212
- def _archive_locked(
213
- project: Project, manifest: Manifest, superseded_by: list[str], why: str, by: str
214
- ) -> Path:
213
+ def archive_runs(
214
+ project: Project,
215
+ manifests: list[Manifest],
216
+ *,
217
+ superseded_by: dict[str, list[str]] | None = None,
218
+ why: str = "",
219
+ by: str = "",
220
+ ) -> list[tuple[str, Path]]:
221
+ """Archive several runs: all are checked under one ``run.lock`` before any moves."""
222
+ replacements = superseded_by or {}
223
+ done: list[tuple[str, Path]] = []
224
+ with project.run_lock():
225
+ for m in manifests:
226
+ m.reload()
227
+ dsts = [_archive_dst(project, m) for m in manifests]
228
+ for m, dst in zip(manifests, dsts, strict=True):
229
+ try:
230
+ _archive_locked(project, m, dst, replacements.get(m.id, []), why, by)
231
+ except (RunopsError, OSError) as exc:
232
+ moved = ", ".join(run_id for run_id, _ in done)
233
+ tail = f" (already archived: {moved})" if moved else ""
234
+ raise RunError(f"{m.id}: {exc}{tail}") from exc
235
+ done.append((m.id, dst))
236
+ return done
237
+
238
+
239
+ def _archive_dst(project: Project, manifest: Manifest) -> Path:
215
240
  status = manifest.status
216
241
  if status not in _TERMINAL:
217
242
  raise UsageError(f"{manifest.id} is {status.value}; only terminal runs can be archived")
@@ -227,6 +252,19 @@ def _archive_locked(
227
252
  dst = archive_root / rel
228
253
  if dst.exists():
229
254
  raise UsageError(f"{dst} already exists")
255
+ return dst
256
+
257
+
258
+ def _archive_locked(
259
+ project: Project,
260
+ manifest: Manifest,
261
+ dst: Path,
262
+ superseded_by: list[str],
263
+ why: str,
264
+ by: str,
265
+ ) -> Path:
266
+ status = manifest.status
267
+ src = manifest.run_dir.resolve()
230
268
  move_noreplace(src, dst)
231
269
  moved = Manifest.load(dst / MANIFEST_FILE)
232
270
  moved.run["status_before_archive"] = status.value
@@ -284,60 +322,96 @@ class MoveResult:
284
322
  _MOVABLE = (RunState.CREATED, RunState.COMPLETED, RunState.FAILED, RunState.CANCELLED)
285
323
 
286
324
 
287
- def move_run(
325
+ @dataclass
326
+ class _MovePlan:
327
+ manifest: Manifest
328
+ status: RunState
329
+ src: Path
330
+ dst: Path
331
+ label: str
332
+
333
+
334
+ def move_runs(
288
335
  project: Project,
289
- manifest: Manifest,
336
+ manifests: list[Manifest],
290
337
  *,
291
338
  dest: str = "",
292
339
  label: str | None = None,
293
340
  why: str = "",
294
341
  by: str = "",
295
- ) -> MoveResult:
296
- """Rename and/or move a run under ``runs/``, keeping its id, inputs and history.
297
-
298
- Submitted/running runs are refused (Slurm holds absolute paths into the run
299
- directory); archived runs move with ``restore``. The old path and *why* are
300
- appended to the run's notes; a created run gets a fresh ``job.sh``.
342
+ ) -> list[MoveResult]:
343
+ """Rename and/or move runs under ``runs/``, keeping their ids, inputs and history.
344
+
345
+ Every run is checked under one ``run.lock`` before any directory moves, so a
346
+ refused run leaves all of them where they were. Submitted/running runs are refused
347
+ (Slurm holds absolute paths into the run directory); archived runs move with
348
+ ``restore``. The old path and *why* are appended to each run's notes; a created run
349
+ gets a fresh ``job.sh``.
301
350
  """
351
+ results: list[MoveResult] = []
302
352
  with project.run_lock():
303
- manifest.reload()
304
- status = manifest.status
305
- if status not in _MOVABLE:
306
- hint = "restore it first" if status == RunState.ARCHIVED else "wait or cancel it"
307
- raise UsageError(f"{manifest.id} is {status.value}; {hint}")
308
- src = manifest.run_dir.resolve()
309
- parent = resolve_dest(project, dest) if dest else src.parent
310
- if inside(parent, src):
311
- raise UsageError(f"--dest must not be inside {manifest.id} itself")
312
- new_label = (
313
- manifest.label
314
- if label is None
315
- else slugify(label, project.config.defaults.label_max_length)
316
- )
317
- if label and not new_label:
318
- raise UsageError(f"--label {label!r} has no usable characters")
319
- name = f"{manifest.id}--{new_label}" if new_label else manifest.id
320
- dst = parent / name
321
- if dst.resolve() == src:
322
- raise UsageError(f"{manifest.id} is already at {project.rel(src)}")
323
- if dst.exists():
324
- raise UsageError(f"{project.rel(dst)} already exists")
325
- parent.mkdir(parents=True, exist_ok=True)
326
- move_noreplace(src, dst)
327
- moved = Manifest.load(dst / MANIFEST_FILE)
328
- moved.run["label"] = new_label
329
- moved.section("path")["run_dir"] = project.rel(dst)
330
- text = f"moved from {project.rel(src)}"
331
- moved.add_note(f"{text}: {why}" if why else text, by or "runops")
332
- moved.save()
333
- project.update_index(moved.id, dst)
334
- result = MoveResult(moved.id, src, dst)
335
- if status == RunState.CREATED:
353
+ plans = [_plan_move(project, m, dest, label) for m in manifests]
354
+ taken: dict[Path, str] = {}
355
+ for plan in plans:
356
+ other = taken.setdefault(plan.dst.resolve(), plan.manifest.id)
357
+ if other != plan.manifest.id:
358
+ raise UsageError(
359
+ f"{other} and {plan.manifest.id} would both move to {project.rel(plan.dst)}"
360
+ )
361
+ for plan in plans:
336
362
  try:
337
- write_job_for(project, moved)
338
- except RunopsError as exc: # submit regenerates job.sh anyway
339
- result.warnings.append(f"{moved.id}: job.sh not regenerated ({exc})")
340
- _prune_empty(src.parent, project.runs_dir)
363
+ results.append(_apply_move(project, plan, why, by))
364
+ except (RunopsError, OSError) as exc:
365
+ done = ", ".join(r.run_id for r in results)
366
+ moved = f" (already moved: {done})" if done else ""
367
+ raise RunError(f"{plan.manifest.id}: {exc}{moved}") from exc
368
+ finally:
369
+ _prune_empty(plan.src.parent, project.runs_dir)
370
+ return results
371
+
372
+
373
+ def _plan_move(project: Project, manifest: Manifest, dest: str, label: str | None) -> _MovePlan:
374
+ manifest.reload()
375
+ status = manifest.status
376
+ if status not in _MOVABLE:
377
+ hint = "restore it first" if status == RunState.ARCHIVED else "wait or cancel it"
378
+ raise UsageError(f"{manifest.id} is {status.value}; {hint}")
379
+ src = manifest.run_dir.resolve()
380
+ parent = resolve_dest(project, dest) if dest else src.parent
381
+ if inside(parent, src):
382
+ raise UsageError(f"--dest must not be inside {manifest.id} itself")
383
+ new_label = (
384
+ manifest.label
385
+ if label is None
386
+ else slugify(label, project.config.defaults.label_max_length)
387
+ )
388
+ if label and not new_label:
389
+ raise UsageError(f"--label {label!r} has no usable characters")
390
+ name = f"{manifest.id}--{new_label}" if new_label else manifest.id
391
+ dst = parent / name
392
+ if dst.resolve() == src:
393
+ raise UsageError(f"{manifest.id} is already at {project.rel(src)}")
394
+ if dst.exists():
395
+ raise UsageError(f"{project.rel(dst)} already exists")
396
+ return _MovePlan(manifest, status, src, dst, new_label)
397
+
398
+
399
+ def _apply_move(project: Project, plan: _MovePlan, why: str, by: str) -> MoveResult:
400
+ plan.dst.parent.mkdir(parents=True, exist_ok=True)
401
+ move_noreplace(plan.src, plan.dst)
402
+ moved = Manifest.load(plan.dst / MANIFEST_FILE)
403
+ moved.run["label"] = plan.label
404
+ moved.section("path")["run_dir"] = project.rel(plan.dst)
405
+ text = f"moved from {project.rel(plan.src)}"
406
+ moved.add_note(f"{text}: {why}" if why else text, by or "runops")
407
+ moved.save()
408
+ project.update_index(moved.id, plan.dst)
409
+ result = MoveResult(moved.id, plan.src, plan.dst)
410
+ if plan.status == RunState.CREATED:
411
+ try:
412
+ write_job_for(project, moved)
413
+ except RunopsError as exc: # submit regenerates job.sh anyway
414
+ result.warnings.append(f"{moved.id}: job.sh not regenerated ({exc})")
341
415
  return result
342
416
 
343
417
 
@@ -5,7 +5,11 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
 
8
+ import pytest
9
+
10
+ from runops.errors import UsageError
8
11
  from runops.manifest import Manifest
12
+ from runops.ops.tidy import archive_runs
9
13
  from runops.project import Project
10
14
 
11
15
 
@@ -96,3 +100,18 @@ def test_archive_series_skips_active_and_pinned(project_dir: Path, cli, fake_slu
96
100
  assert cli("archive", b, "-y").code == 0
97
101
  res = cli("archive", "runs/demo/mix", "-y")
98
102
  assert res.code == 0 and "nothing to archive" in res.out
103
+
104
+
105
+ def test_archive_runs_rechecks_under_the_lock(project_dir: Path, cli, fake_slurm) -> None: # type: ignore[no-untyped-def]
106
+ a, b = cli(
107
+ "sweep", "demo", "--set", "x=1,2", "--label", "s", "--apply", "--submit", "-y"
108
+ ).run_ids()
109
+ assert cli("cancel", b, "-y").code == 0
110
+ _finish_all(cli, fake_slurm)
111
+ project = Project.open(project_dir)
112
+ stale = [project.resolve_run(a), project.resolve_run(b)]
113
+ # Between the preview and the move, the cancelled b is retried and resubmitted.
114
+ assert cli("retry", b, "--submit", "-y").code == 0
115
+ with pytest.raises(UsageError, match=f"{b} is submitted"):
116
+ archive_runs(project, stale)
117
+ assert _manifest(project_dir, a).status.value == "completed"
@@ -5,8 +5,11 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
 
8
+ import pytest
9
+
8
10
  from runops.manifest import Manifest
9
11
  from runops.project import Project
12
+ from runops.util import move_noreplace
10
13
 
11
14
 
12
15
  def _manifest(project_dir: Path, run_id: str) -> Manifest:
@@ -120,3 +123,40 @@ def test_elapsed_from_exit_record(project_dir: Path, cli, fake_slurm) -> None:
120
123
  )
121
124
  cli("sync")
122
125
  assert _manifest(project_dir, run_id).job["attempts"][-1]["elapsed"] == "1-01:30:05"
126
+
127
+
128
+ def test_mv_checks_every_run_before_moving(project_dir: Path, cli, fake_slurm) -> None: # type: ignore[no-untyped-def]
129
+ a, b, c = cli("sweep", "demo", "--set", "x=1,2,3", "--label", "s", "--apply").run_ids()
130
+ before = {r: _manifest(project_dir, r).run_dir for r in (a, b, c)}
131
+ assert cli("submit", b).code == 0
132
+ res = cli("mv", a, b, c, "--dest", "regrouped")
133
+ assert res.code == 2 and f"{b} is submitted" in res.err
134
+ assert {r: _manifest(project_dir, r).run_dir for r in (a, b, c)} == before
135
+ assert not (project_dir / "runs/regrouped").exists()
136
+
137
+ (project_dir / "runs/regrouped" / before[c].name).mkdir(parents=True)
138
+ res = cli("mv", a, c, "--dest", "regrouped")
139
+ assert res.code == 2 and "already exists" in res.err
140
+ assert _manifest(project_dir, a).run_dir == before[a]
141
+
142
+
143
+ def test_mv_reports_runs_moved_before_a_failure(
144
+ project_dir: Path,
145
+ cli, # type: ignore[no-untyped-def]
146
+ monkeypatch: pytest.MonkeyPatch,
147
+ ) -> None:
148
+ a, b = cli("sweep", "demo", "--set", "x=1,2", "--label", "s", "--apply").run_ids()
149
+ calls: list[Path] = []
150
+
151
+ def flaky(src: Path, dst: Path) -> None:
152
+ calls.append(src)
153
+ if len(calls) == 2:
154
+ raise OSError("disk quota exceeded")
155
+ move_noreplace(src, dst)
156
+
157
+ monkeypatch.setattr("runops.ops.tidy.move_noreplace", flaky)
158
+ res = cli("mv", a, b, "--dest", "regrouped")
159
+ assert res.code == 1
160
+ assert f"{b}: disk quota exceeded (already moved: {a})" in res.err
161
+ assert _manifest(project_dir, a).run_dir.parent == project_dir / "runs/regrouped"
162
+ assert _manifest(project_dir, b).run_dir.parent == project_dir / "runs/demo/s"
@@ -525,7 +525,7 @@ wheels = [
525
525
 
526
526
  [[package]]
527
527
  name = "runops"
528
- version = "2.3.0"
528
+ version = "2.3.1"
529
529
  source = { editable = "." }
530
530
  dependencies = [
531
531
  { name = "jinja2" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes