runops 2.6.0__tar.gz → 2.8.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 (150) hide show
  1. {runops-2.6.0 → runops-2.8.0}/PKG-INFO +1 -1
  2. {runops-2.6.0 → runops-2.8.0}/SPEC.md +16 -8
  3. {runops-2.6.0 → runops-2.8.0}/docs/guide.md +10 -2
  4. {runops-2.6.0 → runops-2.8.0}/docs/reference.md +9 -6
  5. {runops-2.6.0 → runops-2.8.0}/pyproject.toml +1 -1
  6. {runops-2.6.0 → runops-2.8.0}/src/runops/__init__.py +1 -1
  7. {runops-2.6.0 → runops-2.8.0}/src/runops/cli.py +38 -29
  8. {runops-2.6.0 → runops-2.8.0}/src/runops/harness.py +2 -2
  9. {runops-2.6.0 → runops-2.8.0}/src/runops/manifest.py +11 -3
  10. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/create.py +92 -9
  11. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/notes.py +6 -6
  12. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/progress.py +16 -0
  13. {runops-2.6.0 → runops-2.8.0}/src/runops/skills/runops-analyze/SKILL.md +9 -4
  14. runops-2.8.0/src/runops/skills/runops-analyze/example-report/README.md +97 -0
  15. runops-2.8.0/src/runops/skills/runops-analyze/example-report/out/fig1_maps.png +0 -0
  16. runops-2.8.0/src/runops/skills/runops-analyze/example-report/out/fig2_dependence.png +0 -0
  17. runops-2.8.0/src/runops/skills/runops-analyze/example-report/out/values.csv +6 -0
  18. runops-2.8.0/src/runops/skills/runops-analyze/example-report/report.html +149 -0
  19. runops-2.8.0/src/runops/skills/runops-analyze/example-report/scripts/build_html.py +72 -0
  20. runops-2.8.0/src/runops/skills/runops-analyze/example-report/scripts/make_figures.py +122 -0
  21. runops-2.8.0/src/runops/skills/runops-analyze/report-guide.md +91 -0
  22. {runops-2.6.0 → runops-2.8.0}/src/runops/skills/runops-check-status/SKILL.md +4 -0
  23. {runops-2.6.0 → runops-2.8.0}/src/runops/skills/runops-reference/SKILL.md +3 -3
  24. {runops-2.6.0 → runops-2.8.0}/tests/test_progress.py +50 -0
  25. {runops-2.6.0 → runops-2.8.0}/tests/test_safety.py +51 -0
  26. {runops-2.6.0 → runops-2.8.0}/tests/test_skills.py +43 -0
  27. {runops-2.6.0 → runops-2.8.0}/uv.lock +1 -1
  28. runops-2.6.0/.harness/manifest.toml +0 -15
  29. {runops-2.6.0 → runops-2.8.0}/.agents/skills/check/SKILL.md +0 -0
  30. {runops-2.6.0 → runops-2.8.0}/.agents/skills/improve-harness/SKILL.md +0 -0
  31. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/SKILL.md +0 -0
  32. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/agents/openai.yaml +0 -0
  33. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/examples/refactor-request.md +0 -0
  34. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/references/anti-patterns.md +0 -0
  35. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/references/api-compatibility.md +0 -0
  36. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/references/packaging-modernization.md +0 -0
  37. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/references/refactor-playbook.md +0 -0
  38. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/references/verification.md +0 -0
  39. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/scripts/api_surface_snapshot.py +0 -0
  40. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/scripts/import_smoke.py +0 -0
  41. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/scripts/inspect_python_package.py +0 -0
  42. {runops-2.6.0 → runops-2.8.0}/.agents/skills/python-package-refactor/scripts/refactor_quality_gate.py +0 -0
  43. {runops-2.6.0 → runops-2.8.0}/.agents/skills/release/SKILL.md +0 -0
  44. {runops-2.6.0 → runops-2.8.0}/.agents/skills/simulator-adapter/SKILL.md +0 -0
  45. {runops-2.6.0 → runops-2.8.0}/.agents/skills/spec-reviewer/SKILL.md +0 -0
  46. {runops-2.6.0 → runops-2.8.0}/.agents/skills/sync-docs/SKILL.md +0 -0
  47. {runops-2.6.0 → runops-2.8.0}/.claude/agents/spec-reviewer.md +0 -0
  48. {runops-2.6.0 → runops-2.8.0}/.claude/rules/architecture.md +0 -0
  49. {runops-2.6.0 → runops-2.8.0}/.claude/rules/dev-workflow.md +0 -0
  50. {runops-2.6.0 → runops-2.8.0}/.claude/rules/gotchas.md +0 -0
  51. {runops-2.6.0 → runops-2.8.0}/.claude/settings.json +0 -0
  52. {runops-2.6.0 → runops-2.8.0}/.claude/skills/check/SKILL.md +0 -0
  53. {runops-2.6.0 → runops-2.8.0}/.claude/skills/improve-harness/SKILL.md +0 -0
  54. {runops-2.6.0 → runops-2.8.0}/.claude/skills/python-package-refactor/SKILL.md +0 -0
  55. {runops-2.6.0 → runops-2.8.0}/.claude/skills/release/SKILL.md +0 -0
  56. {runops-2.6.0 → runops-2.8.0}/.claude/skills/simulator-adapter/SKILL.md +0 -0
  57. {runops-2.6.0 → runops-2.8.0}/.claude/skills/spec-reviewer/SKILL.md +0 -0
  58. {runops-2.6.0 → runops-2.8.0}/.claude/skills/sync-docs/SKILL.md +0 -0
  59. {runops-2.6.0 → runops-2.8.0}/.codex/README.md +0 -0
  60. {runops-2.6.0 → runops-2.8.0}/.codex/agents/spec-reviewer.toml +0 -0
  61. {runops-2.6.0 → runops-2.8.0}/.codex/config.toml +0 -0
  62. {runops-2.6.0 → runops-2.8.0}/.codex/rules/runops.rules +0 -0
  63. {runops-2.6.0 → runops-2.8.0}/.dockerignore +0 -0
  64. {runops-2.6.0 → runops-2.8.0}/.gitattributes +0 -0
  65. {runops-2.6.0 → runops-2.8.0}/.github/assets/conversation-dark.svg +0 -0
  66. {runops-2.6.0 → runops-2.8.0}/.github/assets/conversation-light.svg +0 -0
  67. {runops-2.6.0 → runops-2.8.0}/.github/assets/hero-dark.svg +0 -0
  68. {runops-2.6.0 → runops-2.8.0}/.github/assets/hero-light.svg +0 -0
  69. {runops-2.6.0 → runops-2.8.0}/.github/assets/make_readme_art.py +0 -0
  70. {runops-2.6.0 → runops-2.8.0}/.github/workflows/ci.yml +0 -0
  71. {runops-2.6.0 → runops-2.8.0}/.github/workflows/publish.yml +0 -0
  72. {runops-2.6.0 → runops-2.8.0}/.gitignore +0 -0
  73. {runops-2.6.0 → runops-2.8.0}/.pre-commit-config.yaml +0 -0
  74. {runops-2.6.0 → runops-2.8.0}/.vscode/settings.json +0 -0
  75. {runops-2.6.0 → runops-2.8.0}/AGENTS.md +0 -0
  76. {runops-2.6.0 → runops-2.8.0}/CLAUDE.md +0 -0
  77. {runops-2.6.0 → runops-2.8.0}/LICENSE +0 -0
  78. {runops-2.6.0 → runops-2.8.0}/README.md +0 -0
  79. {runops-2.6.0 → runops-2.8.0}/docs/adapter.md +0 -0
  80. {runops-2.6.0 → runops-2.8.0}/src/runops/adapters/__init__.py +0 -0
  81. {runops-2.6.0 → runops-2.8.0}/src/runops/adapters/base.py +0 -0
  82. {runops-2.6.0 → runops-2.8.0}/src/runops/adapters/generic.py +0 -0
  83. {runops-2.6.0 → runops-2.8.0}/src/runops/adapters/registry.py +0 -0
  84. {runops-2.6.0 → runops-2.8.0}/src/runops/case.py +0 -0
  85. {runops-2.6.0 → runops-2.8.0}/src/runops/config.py +0 -0
  86. {runops-2.6.0 → runops-2.8.0}/src/runops/errors.py +0 -0
  87. {runops-2.6.0 → runops-2.8.0}/src/runops/jobgen.py +0 -0
  88. {runops-2.6.0 → runops-2.8.0}/src/runops/launchers.py +0 -0
  89. {runops-2.6.0 → runops-2.8.0}/src/runops/legacy_adapters/__init__.py +0 -0
  90. {runops-2.6.0 → runops-2.8.0}/src/runops/legacy_adapters/beach.py +0 -0
  91. {runops-2.6.0 → runops-2.8.0}/src/runops/legacy_adapters/emses.py +0 -0
  92. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/__init__.py +0 -0
  93. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/analysis.py +0 -0
  94. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/common.py +0 -0
  95. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/listing.py +0 -0
  96. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/migrate.py +0 -0
  97. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/setup.py +0 -0
  98. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/skills.py +0 -0
  99. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/submit.py +0 -0
  100. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/sync.py +0 -0
  101. {runops-2.6.0 → runops-2.8.0}/src/runops/ops/tidy.py +0 -0
  102. {runops-2.6.0 → runops-2.8.0}/src/runops/project.py +0 -0
  103. {runops-2.6.0 → runops-2.8.0}/src/runops/runtime.py +0 -0
  104. {runops-2.6.0 → runops-2.8.0}/src/runops/skills/runops-create-run/SKILL.md +0 -0
  105. {runops-2.6.0 → runops-2.8.0}/src/runops/skills/runops-maintain/SKILL.md +0 -0
  106. {runops-2.6.0 → runops-2.8.0}/src/runops/skills/runops-tidy/SKILL.md +0 -0
  107. {runops-2.6.0 → runops-2.8.0}/src/runops/slurm.py +0 -0
  108. {runops-2.6.0 → runops-2.8.0}/src/runops/util.py +0 -0
  109. {runops-2.6.0 → runops-2.8.0}/tests/__init__.py +0 -0
  110. {runops-2.6.0 → runops-2.8.0}/tests/conftest.py +0 -0
  111. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/.codex/rules/a.md +0 -0
  112. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/.runops/test-runs/T20260701-0001/receipt.toml +0 -0
  113. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/.runops/work/beach-x-abc/bin/beach +0 -0
  114. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/AGENTS.md +0 -0
  115. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/CLAUDE.md +0 -0
  116. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/cases/beach/plane/beach.toml +0 -0
  117. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/cases/beach/plane/case.toml +0 -0
  118. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/cases/old/beach.toml +0 -0
  119. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/cases/old/case.toml +0 -0
  120. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/launchers.toml +0 -0
  121. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/research/CURRENT.md +0 -0
  122. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runops.toml +0 -0
  123. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runs/AGENTS.md +0 -0
  124. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runs/_archive/plane/R20260601-0001/manifest.toml +0 -0
  125. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/input/beach.toml +0 -0
  126. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runs/plane/R20260701-0001--a/manifest.toml +0 -0
  127. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runs/plane/R20260702-0001/manifest.toml +0 -0
  128. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runs/scan/R20260701-0002/manifest.toml +0 -0
  129. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/runs/scan/survey.toml +0 -0
  130. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/simulators.toml +0 -0
  131. {runops-2.6.0 → runops-2.8.0}/tests/fixtures/v1-project/site.toml +0 -0
  132. {runops-2.6.0 → runops-2.8.0}/tests/golden/mpi_standard.sh +0 -0
  133. {runops-2.6.0 → runops-2.8.0}/tests/golden/openmp_rsc.sh +0 -0
  134. {runops-2.6.0 → runops-2.8.0}/tests/test_adapters.py +0 -0
  135. {runops-2.6.0 → runops-2.8.0}/tests/test_analysis_layout.py +0 -0
  136. {runops-2.6.0 → runops-2.8.0}/tests/test_archive_series.py +0 -0
  137. {runops-2.6.0 → runops-2.8.0}/tests/test_build.py +0 -0
  138. {runops-2.6.0 → runops-2.8.0}/tests/test_dev_harness.py +0 -0
  139. {runops-2.6.0 → runops-2.8.0}/tests/test_jobgen.py +0 -0
  140. {runops-2.6.0 → runops-2.8.0}/tests/test_legacy_adapters.py +0 -0
  141. {runops-2.6.0 → runops-2.8.0}/tests/test_lifecycle.py +0 -0
  142. {runops-2.6.0 → runops-2.8.0}/tests/test_manifest_config.py +0 -0
  143. {runops-2.6.0 → runops-2.8.0}/tests/test_migrate.py +0 -0
  144. {runops-2.6.0 → runops-2.8.0}/tests/test_mv_naming.py +0 -0
  145. {runops-2.6.0 → runops-2.8.0}/tests/test_project_scope.py +0 -0
  146. {runops-2.6.0 → runops-2.8.0}/tests/test_run_job.py +0 -0
  147. {runops-2.6.0 → runops-2.8.0}/tests/test_scratch_tidy.py +0 -0
  148. {runops-2.6.0 → runops-2.8.0}/tests/test_setup_misc.py +0 -0
  149. {runops-2.6.0 → runops-2.8.0}/tests/test_sweep.py +0 -0
  150. {runops-2.6.0 → runops-2.8.0}/tests/test_sync_evidence.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runops
3
- Version: 2.6.0
3
+ Version: 2.8.0
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
@@ -475,8 +475,12 @@ adapter が `progress(run_dir)` を持つとき、`sync` は running の run と
475
475
  上限 1000 件程度)。queue を離れたときの観測は終了時刻で記録し、終了後の待ち時間を速度に含めない。
476
476
  - 要約を `[[job.attempts]].progress` に置く: `done`、`total`、`unit`、`start`、`fraction`、
477
477
  `rate_recent_per_hour`(直近 10 分)、`rate_attempt_per_hour`(program 開始から。起点は
478
- `start`)、`eta_hours`(running のみ)。program 開始時刻は job.sh が stdout に出す
478
+ `start`)、`eta_hours`(running のみ)、`last_change_at`(`done` が最後に増えた観測時刻)、
479
+ `stalled_hours`(最新観測が `last_change_at` から 1 時間以上進んでいないとき、その長さ。
480
+ 終了後の観測には付けない)。program 開始時刻は job.sh が stdout に出す
479
481
  `runops: start` 行から取り、`started_at` に入れる。
482
+ - `status` / `list` は `stalled_hours` があれば `stalled 6.7h` と表示する。観測は `sync` のときだけ
483
+ なので、停止の検出は `sync` の間隔より細かくならない。
480
484
  - `status` / `list` は要約だけを読む(file を開かない)。read-only command は観測を足さない。
481
485
  - 解析は adapter が行い、runops は保存と表示だけを持つ(§1.2 #4)。
482
486
 
@@ -497,7 +501,8 @@ adapter が `progress(run_dir)` を持つとき、`sync` は running の run と
497
501
  `--json` は作成した run の同じ summary を返す。summary には `params`、`changed_keys`、
498
502
  `parent_run`、`exe_hash`、`input_hash` を含め、run 同士の比較に別 command を要らなくする。
499
503
  walltime の根拠として `walltime`、最新 attempt の `elapsed`、`walltime_used`(elapsed / walltime)、
500
- `slurm_state`(`TIMEOUT` など)も含める。
504
+ `slurm_state`(`TIMEOUT` など)も含める。`list` / `status` の `--brief` は summary から
505
+ `params`、`why`、`last_note` を外す(多数の run を横断して渡すとき用。表では note / why 列を省く)。
501
506
  - 破壊的 command(`cancel`、`delete`、`tidy --apply`、`archive`)は対象を表示して
502
507
  確認を取る。`-y/--yes` で省略。
503
508
  - exit code: 0 成功、1 対象 run の実行時エラー(例: sbatch 失敗)、2 使用方法・設定エラー。
@@ -518,7 +523,7 @@ adapter が `progress(run_dir)` を持つとき、`sync` は running の run と
518
523
  | `runo doctor [--json]` | `runops.toml` の妥当性、adapter の import 可否、executable の存在、`sbatch` の有無、skill の鮮度を検査 |
519
524
  | `runo update [--skills-only] [-y]` | adapter package を upgrade し、skill を再展開する |
520
525
  | `runo build SIM [--force]` | `local_source` simulator を `.runops/build/` に build する(§4.1) |
521
- | `runo context [--json]` | agent 向け現在地。project 名、simulator、active run 数、直近 run 5 件、scratch 期限切れ数、`LOG.md` 末尾 20 行、現用 group の解析 topic 名(§9.1)。配置した runops skill が install 済みの runops より古ければ `runo update --skills-only` を促す warning |
526
+ | `runo context [--json]` | agent 向け現在地。project 名、simulator、active run 数と全 active run(`--brief` と同じ summary、`active`)、直近 run 5 件、scratch 期限切れ数、`LOG.md` 末尾 20 行、現用 group の解析 topic 名(§9.1)。配置した runops skill が install 済みの runops より古ければ `runo update --skills-only` を促す warning |
522
527
  | `runo case new NAME --simulator SIM [--from CASE]` | case をスキャフォールド。adapter の `case_template()` を展開 |
523
528
  | `runo case list` | case 一覧 |
524
529
  | `runo new CASE [--why TEXT] [--label L] [--set k=v]... [--job k=v]... [--tag T]... [--scratch] [--dest DIR] [--submit] [--created-by WHO] [--json]` | 単発 run を作る |
@@ -528,10 +533,10 @@ adapter が `progress(run_dir)` を持つとき、`sync` は running の run と
528
533
  | `runo submit --all [DIR] [-y]` | DIR 配下の `created` run をすべて投入。件数と概算 core-hours を表示して確認 |
529
534
  | `runo cancel RUN... [--why TEXT] [-y]` | scancel して sync。`--why` は `cancelled: ...` の note として残す |
530
535
  | `runo retry RUN [--submit] [-y]` | `failed`/`cancelled` を `created` に戻す。直近の cancel に `--why` があれば理由を示して確認を取る |
531
- | `runo status [RUN...] [--json]` | manifest の状態を表示。Slurm を呼ばない |
536
+ | `runo status [RUN...] [--brief] [--json]` | manifest の状態を表示。Slurm を呼ばない。`--brief` は §7.1 |
532
537
  | `runo sync [RUN...] [--json]` | squeue/sacct で状態を更新。引数なしは `submitted`/`running` 全件 |
533
538
  | `runo log RUN [--err] [-n N] [-f]` | 最新 job の stdout/stderr を表示。`-f` は tail -f |
534
- | `runo list [PATH] [--all] [--since 30d] [--case C] [--sweep L] [--status S]... [--tag T]... [--scratch] [--archived] [--json]` | run 一覧(§7.4) |
539
+ | `runo list [PATH] [--all] [--since 30d] [--case C] [--sweep L] [--status S]... [--tag T]... [--scratch] [--archived] [--brief] [--json]` | run 一覧(§7.4) |
535
540
  | `runo note RUN [TEXT] [--by WHO]` | TEXT があれば `[[notes]]` に追記、なければ notes と `notes.md` を表示 |
536
541
  | `runo pin RUN [--off] [--reason TEXT]` | `run.pinned` を切り替える。pinned は `list` に常に出て、`tidy` / `delete` の対象外 |
537
542
  | `runo summarize RUN... [--force]` | adapter の `summarize()` を呼び `analysis/summary.json` を書く。既存があれば `--force` なしではスキップ |
@@ -550,7 +555,8 @@ adapter が `progress(run_dir)` を持つとき、`sync` は running の run と
550
555
  - 既定で表示しない: scratch run、archived run、上記以外の古い terminal run。
551
556
  - `--all` は scratch と archived 以外をすべて表示。`--scratch`、`--archived` は
552
557
  それぞれを **追加** する。
553
- - 表示列: id、status、label、case、sweep、created(相対時刻)、最新 note の先頭 60 文字、`why`。
558
+ - 表示列: id、status、label、case、sweep、created(相対時刻)、elapsed、progress、最新 note の先頭 60 文字、`why`。
559
+ `--brief` は note と `why` の列を省く。
554
560
  - sort は `created_at` 降順。
555
561
  - `PATH` を与えた場合はその directory 配下に限定する。
556
562
 
@@ -723,14 +729,14 @@ runops は `init` と `update` で次を行う。
723
729
  runops は次の 6 skill だけを同梱し、simulator skill と同じ仕組みで展開する。
724
730
  利用者が覚えるコマンドは `runo init` だけとし、それ以外(simulator の追加、更新、v1 移行を含む)は
725
731
  エージェントへの依頼で行う。
726
- 各 SKILL.md は 60 行以内に収める。
732
+ 各 SKILL.md は 60 行以内に収める。詳しい指針や例が要る skill は、同じ directory に別 file として置き、SKILL.md から相対リンクで参照する。
727
733
 
728
734
  | skill | 内容 |
729
735
  |---|---|
730
736
  | `runops-create-run` | `new` / `sweep` の使い方。`--why` を必ず書く、scratch の使い分け |
731
737
  | `runops-check-status` | `sync` → `list` → `log` の順。失敗時の `retry` と `tidy --purge-work` |
732
738
  | `runops-tidy` | `tidy` を定期的に実行する判断基準。pin の使い方 |
733
- | `runops-analyze` | 依存する run の範囲で調査の置き場所を決め、script・結果・log・README をまとめる |
739
+ | `runops-analyze` | 依存する run の範囲で調査の置き場所を決め、script・結果・log・README をまとめる。読み手に渡す比較レポートの書き方は同梱の `report-guide.md` と `example-report/` に置く |
734
740
  | `runops-reference` | command 一覧(§7.3 の表をそのまま載せる) |
735
741
  | `runops-maintain` | simulator の追加、runops と simulator package の更新、`migrate-v1`、`doctor` の読み方 |
736
742
 
@@ -1011,6 +1017,7 @@ v2 の文書は次の 4 ファイルだけにする。
1011
1017
 
1012
1018
  | 項目 | 決定 | 理由 |
1013
1019
  |---|---|---|
1020
+ | 比較レポートの書き方(2026-11-10) | `runops-analyze` に執筆ガイド `report-guide.md` と模擬データの例 `example-report/` を同梱する。README.md を本文の正本とし、例の `scripts/build_html.py` が図を埋め込んだ `report.html` を作る。command やレポート生成機構は足さない | 差分図と条件の列挙が中心のレポートは読み手に伝わらず、元の分布を同じ尺度で並べ、問い・観測・解釈をつなげて書き直すと伝わった(#119)。書き方は判断を伴うので、手順と例として skill に置く |
1014
1021
  | 解析の配置 2.6.0(2026-10-23) | 依存する run の範囲に応じて run 内 `analysis/`、group の `_analysis/<topic>/`、project の `analysis/<topic>/` にまとめる。skill、log の tidy、group archive、context を対応させる(§9.1) | 報告・図・script・job log が分散・混在していた。group は既存の directory として扱い、新しい entity や command は足さない。既存ファイルの整理は移動一覧を提示してから行う |
1015
1022
  | job の cwd | run_dir(v1 と同じ)。Slurm log は `work/` | v1 adapter の path 規約と実 project の job.sh をそのまま使える |
1016
1023
  | adapter の追加 hook | `configure(sim_config)`、`job_prelude(run_dir)` | generic の `command` 取得と、EMSES の `rm -f *_0000.h5` など v1 の setup command を表す |
@@ -1038,5 +1045,6 @@ v2 の文書は次の 4 ファイルだけにする。
1038
1045
  | 生成ハーネスの書き方(2026-10-21) | AGENTS.md の Rules を「作業の進め方」5 項目に絞り、各項目を「何をするか」と短い理由で書く。skill は手順に専念し、AGENTS.md の方針(manifest を手で編集しない等)を繰り返さない | #112・#113 で規則が増え、禁止形(never / do not)が並んでいた。現行の Claude の prompting 指針は、しないことより何をするかを、理由と共に書くことを勧め、強い禁止語は過剰反応を招くとしている |
1039
1046
  | 複数 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 で同じことが起き得た |
1040
1047
  | 進捗と系列配置 2.5.0(2026-10-23) | adapter に任意の `progress()`、`sync` が時刻付き観測を保存し速度・残り時間を要約(§6.4)。`new --from` の既定置き場所を元 run と同じ directory に。agent context の `docs/` も展開 | 速度比較のために agent が時刻付き batch 観測を手で保存し、再開点や終了後の待ちを毎回補正していた(4 回)。継続世代が case 直下に並び、同じ計算の系列を見分けにくかった。skill が package の `docs/` を参照していたが展開されていなかった。進捗の解析は simulator 固有なので adapter が持ち、runops は保存と表示だけにする |
1048
+ | 横断の状態確認 2.7.0(2026-10-30) | `list` / `status` に `--brief`、`context` に全 active run の `active`、progress 要約に `last_change_at` と `stalled_hours`(§6.4、§7.1、§7.3) | hub で 4 project の active run を集めるのに `recent_runs` 5 件では足りず、全 `params` 付きの `list --json` は shell 引数長を超えた(3 回)。進捗が 6 時間止まった run を 49% と報告した。新しい command は足さず、既存の summary を小さくし、観測から止まりを出す |
1041
1049
  | FEEDBACK 棚卸し 2.4.0(2026-10-23) | `--job k=v` と `new --from` の `[job]` 継承、created の archive / restore、archived scratch を期限削除しない、展開 skill の references 参照の書き換えと `doctor` のリンク検査、adapter warning への版の併記、生成 AGENTS.md に解析環境の置き場所 | hub の FEEDBACK で、資源変更のための case 編集(3 回)、置換済み draft を archive できず helper で迂回(2 回)、保管目的の archive が期限削除される、skill から reference に辿れない、新版の正規キーが誤記に見える、解析依存の置き場所が project ごとに違う、が記録された。いずれも既存 command の拡張で、command 数は増やさない |
1042
1050
  | 使い方ガイド | `docs/guide.md` を追加し、文書を 4 ファイルにする(§13.3) | README の依頼例の表と command 一覧の間を埋める、場面ごとの流れ(case と run の分け方、smoke から本計算、状態の決まり方、整理)の置き場所が無かった |
@@ -60,7 +60,8 @@ runops が扱う名詞は case、run、sweep の 3 つです。
60
60
  作った run の `manifest.toml` と `input/` は変更しません。入力を直したいときは新しい run を作ります。
61
61
  同じ入力の run が既にあると、作成時に警告が出ます。
62
62
  `new --from` は元の run の params を引き継ぎますが、入力ファイルは今の case から作ります。
63
- 元の run を作った後に case の入力ファイルを直していると、作成時に警告が出ます。
63
+ 元の run を作った後に case の入力ファイルを直して値が変わると、作成時の警告に変わる key と値が並びます。
64
+ 元の値のまま続けたいときは `--set` で指定します。
64
65
 
65
66
  ### 名前の付け方
66
67
 
@@ -134,7 +135,10 @@ walltime は、同じ case・同じ資源で終わった run の実時間を元
134
135
  adapter が進捗に対応していれば(BEACH の batch、MPIEMSES3D の step)、`sync` のたびに時刻付きの
135
136
  観測を残し、`runo list` / `status` に `62% 0.198 s/batch eta 3.1h` のように進み具合、直近 10 分の
136
137
  速度、残り時間を出します。resume した run は再開点から数え、終わった run は終了時刻までで速度を出すので、
137
- 版を切り替えた前後の比較にそのまま使えます(`--json` の `progress`)。
138
+ 版を切り替えた前後の比較にそのまま使えます(`--json` の `progress`)。`sync` を重ねても進みが 1 時間以上
139
+ 変わらない run には `stalled 6.7h` が付くので、Slurm では RUNNING でも止まっている run を見分けられます。
140
+ 全 project を横断して見るときは `runo context --json` の `active`(全 active run)か `runo list --brief --json`
141
+ (`params` や note を省いた小さい JSON)を使います。
138
142
 
139
143
  ### 状態の決まり方
140
144
 
@@ -229,6 +233,10 @@ topic は `speed-by-version` のように問いの内容で名付け、同じ問
229
233
 
230
234
  エージェントは `runops-analyze` skill を使い、調査の README と、そこへのリンクを含む
231
235
  `LOG.md` の判断を更新します。`notes/` は run に依存しない知見に使います。
236
+ 比較の結果を人に見せるレポートを頼むと(「この 3 条件を比較してレポートにまとめて」)、
237
+ エージェントは skill 同梱の執筆ガイドと例に沿って、問いと結論、条件を同じ尺度で並べた図、
238
+ 図の読み方と結果の意味、再現情報の順に書きます。README.md と、図を埋め込んで単体で
239
+ 読める `report.html` の両方ができます。
232
240
  解析の追加依存は `.runops/analysis-env/` に置き、重い読み込みや作図はサイトの規則に従って
233
241
  計算ノードで実行します。sbatch は調査 directory から投入し、事前に `logs/` を作って
234
242
  `#SBATCH -o logs/%x.%j.out` と `#SBATCH -e logs/%x.%j.err` を指定します。
@@ -129,8 +129,10 @@ program を起動せずに終わり、`stage = "setup"` と exit code を記録
129
129
 
130
130
  `submit` は executable を hash し直して attempt に記録し、作成時と違えば警告します。
131
131
  `new` と `sweep --apply` は同じ入力(`input_hash`)の run が既にあれば警告します。
132
- `new --from RUN` は、元の run を作った後に case の入力ファイルが変わっていれば警告します
133
- (入力は今の case から作るため)。どれも作成や投入は拒否しません。
132
+ `new --from RUN` は、元の run を作った後に case の入力ファイルが変わり、そのために入力の値が
133
+ 元の run と違うとき、違う key と値を警告に並べます(入力は今の case から作るため)。TOML 以外の
134
+ 入力は file 名だけを示します。`--set` した key と、adapter が `--continue` のために書いた key は
135
+ 並べません。どれも作成や投入は拒否しません。
134
136
 
135
137
  `submit`、`cancel`、`retry`、`sync` は lock の中で manifest を読み直してから更新します。
136
138
  同じ run を 2 回指定しても、複数のエージェントが同時に投入しても sbatch は 1 回です。
@@ -141,7 +143,8 @@ run id は削除後も再利用しません。`--dest` に既存 run の中は
141
143
  `list --json` と `status --json` は、walltime の根拠として `walltime`、最新 attempt の `elapsed`、
142
144
  `walltime_used`(elapsed / walltime)、`slurm_state` も返します。adapter が `progress()` を持てば
143
145
  `progress`(`done`、`total`、`unit`、`fraction`、`rate_recent_per_hour`、`rate_attempt_per_hour`、
144
- `eta_hours`)も返し、表の `progress` 列に `62% 0.198 s/batch eta 3.1h` と出します。表では `TIMEOUT` などを
146
+ `eta_hours`、`last_change_at`、`stalled_hours`)も返し、表の `progress` 列に `62% 0.198 s/batch eta 3.1h` と出します。
147
+ `sync` の観測で `done` が 1 時間以上増えていなければ `stalled 6.7h` が付きます(観測は `sync` のときだけ)。表では `TIMEOUT` などを
145
148
  `failed (TIMEOUT)` と示します。
146
149
 
147
150
  全 command は `--help` で option を確認できます。`RUN` には run id、run directory、
@@ -153,15 +156,15 @@ run id は削除後も再利用しません。`--dest` に既存 run の中は
153
156
  | `runo doctor [--json]` | 設定、adapter の解決経路、executable、sbatch、skill の鮮度とリンク切れを点検 |
154
157
  | `runo update [--skills-only] [-y]` | simulator package を更新し skill と AGENTS.md の管理 block を再生成 |
155
158
  | `runo build SIM [--force]` | `local_source` の simulator を build |
156
- | `runo context [--json]` | agent 向けの現在地と現用 group の解析 topic 名(JSON の `group_analysis` は group path → topic 名一覧) |
159
+ | `runo context [--json]` | agent 向けの現在地。全 active run(`active`、`--brief` と同じ形)、直近 5 run、現用 group の解析 topic 名(JSON の `group_analysis` は group path → topic 名一覧) |
157
160
  | `runo case new NAME [-s SIM] [--from CASE]` / `runo case list` | case の作成と一覧 |
158
161
  | `runo new CASE [--why] [--label] [--set k=v]... [--job k=v]... [--tag] [--scratch] [--dest DIR] [--submit] [--json]` | run を 1 つ作る。`--job` はこの run の `[job]` の値 |
159
162
  | `runo new --from RUN [--set k=v]... [--job k=v]... [--job-from-case] [--continue]` | 既存 run の params と `[job]` から派生(`--job-from-case` で `[job]` は case から)。置き場所の既定は元 run と同じ directory。`--continue` は adapter の restart 対応が必要 |
160
163
  | `runo sweep CASE --set k=v1,v2 ... [--names k=n1,n2...] [--job k=v]... [--label] [--dest DIR] [--limit N] [--apply] [--submit] [--json]` | 直積の候補表示。`--apply` で作成。`--names` は各値の短い label、`--dest` は sweep directory の親 |
161
164
  | `runo submit RUN... [-p P] [--qos Q] [--afterok JOB]` / `runo submit --all [DIR] [-y]` | job.sh を生成して sbatch |
162
165
  | `runo cancel RUN... [--why TEXT] [-y]` / `runo retry RUN [--submit] [-y]` | scancel(`--why` は note に残る)/ failed・cancelled を created に戻す(理由付きで cancel された run は確認を取る) |
163
- | `runo status [RUN...]` / `runo sync [RUN...]` / `runo log RUN [--err] [-n N] [-f]` | 状態表示 / Slurm から更新 / log 表示 |
164
- | `runo list [PATH] [--all] [--since 30d] [--case] [--sweep] [--status] [--tag] [--scratch] [--archived] [--json]` | run 一覧 |
166
+ | `runo status [RUN...] [--brief]` / `runo sync [RUN...]` / `runo log RUN [--err] [-n N] [-f]` | 状態表示 / Slurm から更新 / log 表示 |
167
+ | `runo list [PATH] [--all] [--since 30d] [--case] [--sweep] [--status] [--tag] [--scratch] [--archived] [--brief] [--json]` | run 一覧。`--brief` は `params`、`why`、note を省く(表では note / why 列) |
165
168
  | `runo note RUN [TEXT]` / `runo pin RUN [--off] [--reason]` | ノート追記・表示 / pin |
166
169
  | `runo summarize RUN... [--force]` | adapter の要約を `analysis/summary.json` に書く |
167
170
  | `runo mv RUN... [--dest DIR] [--label L] [--why TEXT]` | `runs/` 配下で改名・移動。id、入力、sweep、ノートは保つ。submitted / running と archived は不可。全対象を検査してから移し、1 件でも不可なら何も動かさない |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runops"
7
- version = "2.6.0"
7
+ version = "2.8.0"
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.6.0"
3
+ __version__ = "2.8.0"
@@ -257,6 +257,16 @@ def context(as_json: bool = typer.Option(False, "--json")) -> None:
257
257
  f"runs: {counts or 'none'}; active: {data['active_runs']}; "
258
258
  f"expired scratch: {data['expired_scratch']}"
259
259
  )
260
+ if data["active"]:
261
+ from runops.ops.progress import format_progress
262
+
263
+ out.print("active:")
264
+ for run in data["active"]:
265
+ out.print(
266
+ f" {run['id']} {run['status']:9} {run['case']} {run['label']} "
267
+ f"{format_progress(run['progress'])}"
268
+ )
269
+ out.print("recent:")
260
270
  for run in data["recent_runs"]:
261
271
  out.print(
262
272
  f" {run['id']} {run['status']:9} {run['case']} {run['label']} {run['why'][:60]}"
@@ -592,23 +602,14 @@ def retry(
592
602
  _submit_each(project, [m])
593
603
 
594
604
 
595
- def _status_table(project: Project, manifests: list[Manifest]) -> Table:
605
+ def _status_table(project: Project, manifests: list[Manifest], *, brief: bool = False) -> Table:
596
606
  from runops.ops.listing import relative_age
597
607
  from runops.ops.progress import format_progress
598
608
 
599
- table = Table(
600
- "id",
601
- "status",
602
- "label",
603
- "case",
604
- "sweep",
605
- "created",
606
- "elapsed",
607
- "progress",
608
- "note",
609
- "why",
610
- box=None,
611
- )
609
+ columns = ["id", "status", "label", "case", "sweep", "created", "elapsed", "progress"]
610
+ if not brief:
611
+ columns += ["note", "why"]
612
+ table = Table(*columns, box=None)
612
613
  for m in manifests:
613
614
  s = m.to_summary(project.root)
614
615
  color = {
@@ -630,7 +631,7 @@ def _status_table(project: Project, manifests: list[Manifest]) -> Table:
630
631
  status += " [dim](pinned)[/]"
631
632
  if s["superseded_by"]:
632
633
  status += f" [dim](-> {', '.join(s['superseded_by'])})[/]"
633
- table.add_row(
634
+ row = [
634
635
  s["id"],
635
636
  status,
636
637
  s["label"],
@@ -639,15 +640,19 @@ def _status_table(project: Project, manifests: list[Manifest]) -> Table:
639
640
  relative_age(s["created_at"]),
640
641
  elapsed,
641
642
  format_progress(s["progress"]),
642
- s["last_note"][:60],
643
- s["why"][:60],
644
- )
643
+ ]
644
+ if not brief:
645
+ row += [s["last_note"][:60], s["why"][:60]]
646
+ table.add_row(*row)
645
647
  return table
646
648
 
647
649
 
648
650
  @app.command()
649
651
  def status(
650
652
  runs: Optional[list[str]] = typer.Argument(None),
653
+ brief: bool = typer.Option(
654
+ False, "--brief", help="Leave out params, why, notes and the note/why columns."
655
+ ),
651
656
  as_json: bool = typer.Option(False, "--json"),
652
657
  ) -> None:
653
658
  """Show manifest state (no Slurm query). Without RUN: active runs."""
@@ -662,18 +667,19 @@ def status(
662
667
  if as_json:
663
668
  data = []
664
669
  for m in targets:
665
- s = m.to_summary(project.root)
666
- s.update(
667
- {
668
- "params": m.params,
669
- "job": {k: v for k, v in m.job.items() if k != "attempts"},
670
- "notes": m.notes,
671
- }
672
- )
670
+ s = m.to_summary(project.root, brief=brief)
671
+ if not brief:
672
+ s.update(
673
+ {
674
+ "params": m.params,
675
+ "job": {k: v for k, v in m.job.items() if k != "attempts"},
676
+ "notes": m.notes,
677
+ }
678
+ )
673
679
  data.append(s)
674
680
  _print_json(data)
675
681
  return
676
- out.print(_status_table(project, targets))
682
+ out.print(_status_table(project, targets, brief=brief))
677
683
 
678
684
 
679
685
  @app.command("sync")
@@ -730,6 +736,9 @@ def list_cmd(
730
736
  tags: Optional[list[str]] = typer.Option(None, "--tag"),
731
737
  scratch: bool = typer.Option(False, "--scratch", help="Also show scratch runs."),
732
738
  archived: bool = typer.Option(False, "--archived", help="Also show archived runs."),
739
+ brief: bool = typer.Option(
740
+ False, "--brief", help="Leave out params, why and notes (small JSON, short table)."
741
+ ),
733
742
  as_json: bool = typer.Option(False, "--json"),
734
743
  ) -> None:
735
744
  """List active and recent runs."""
@@ -752,12 +761,12 @@ def list_cmd(
752
761
  )
753
762
  runs = select_runs(project, flt)
754
763
  if as_json:
755
- _print_json([m.to_summary(project.root) for m in runs])
764
+ _print_json([m.to_summary(project.root, brief=brief) for m in runs])
756
765
  return
757
766
  if not runs:
758
767
  out.print("no runs (try --all, --scratch, --archived)")
759
768
  return
760
- out.print(_status_table(project, runs))
769
+ out.print(_status_table(project, runs, brief=brief))
761
770
 
762
771
 
763
772
  @app.command()
@@ -12,14 +12,14 @@ END = "<!-- runops:end -->"
12
12
  COMMANDS = """\
13
13
  | command | effect |
14
14
  |---|---|
15
- | `runo context --json` | active runs, recent runs, group analysis topics, LOG.md tail |
15
+ | `runo context --json` | all active runs (with progress), recent runs, group analysis topics, LOG.md tail |
16
16
  | `runo case list` / `runo case new NAME --simulator SIM [--from CASE]` | list / scaffold cases |
17
17
  | `runo new CASE --why TEXT [--set k=v]... [--job k=v]... [--scratch] [--submit]` | create one run |
18
18
  | `runo new --from RUN --why TEXT [--set k=v]... [--job k=v]... [--continue]` | derive a run (keeps its params and [job]) |
19
19
  | `runo sweep CASE --set k=v1,v2 [--names k=n1,n2] [--apply] [--submit]` | preview / create a parameter product |
20
20
  | `runo submit RUN... [--partition P]` / `runo submit --all DIR` | sbatch created runs |
21
21
  | `runo sync` / `runo status RUN` / `runo log RUN [--err]` | refresh state from Slurm / show / read logs |
22
- | `runo list [--all] [--case C] [--status S] [--json]` | recent + active runs (old, scratch, archived hidden) |
22
+ | `runo list [--all] [--case C] [--status S] [--brief] [--json]` | recent + active runs (old, scratch, archived hidden); `--brief` drops params/why/notes |
23
23
  | `runo cancel RUN [--why T]` / `runo retry RUN` | stop a job / reset failed or cancelled to created |
24
24
  | `runo note RUN "text"` / `runo pin RUN --reason TEXT` | append an observation / protect a key run |
25
25
  | `runo summarize RUN` | adapter summary into analysis/summary.json |
@@ -188,14 +188,18 @@ class Manifest:
188
188
  notes = self.data.setdefault("notes", [])
189
189
  notes.append({"at": now_iso(), "by": by, "text": text})
190
190
 
191
- def to_summary(self, root: Path) -> dict[str, Any]:
192
- """Flat dict for ``list --json`` / ``status --json``."""
191
+ def to_summary(self, root: Path, *, brief: bool = False) -> dict[str, Any]:
192
+ """Flat dict for ``list --json`` / ``status --json``.
193
+
194
+ *brief* leaves out ``params``, ``why`` and ``last_note``, so a listing of many
195
+ runs stays small enough to pass around (e.g. a cross-project status table).
196
+ """
193
197
  notes = self.notes
194
198
  try:
195
199
  rel = self.run_dir.resolve().relative_to(root.resolve()).as_posix()
196
200
  except ValueError:
197
201
  rel = str(self.run_dir)
198
- return {
202
+ summary = {
199
203
  "id": self.id,
200
204
  "status": self.status.value,
201
205
  "label": self.label,
@@ -222,6 +226,10 @@ class Manifest:
222
226
  **self.timing(),
223
227
  "progress": self.progress,
224
228
  }
229
+ if brief:
230
+ for key in ("params", "why", "last_note"):
231
+ del summary[key]
232
+ return summary
225
233
 
226
234
  @property
227
235
  def progress(self) -> dict[str, Any] | None:
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import itertools
6
7
  import shutil
8
+ from collections.abc import Iterable
7
9
  from dataclasses import dataclass, field
8
10
  from datetime import datetime, timedelta
9
11
  from pathlib import Path
@@ -28,7 +30,17 @@ from runops.ops.common import SimContext, resolve_dest, sim_context
28
30
  from runops.ops.submit import write_job_for
29
31
  from runops.project import ARCHIVE_DIR, SCRATCH_DIR, Project
30
32
  from runops.runtime import resolve_runtime
31
- from runops.util import hash_tree, move_noreplace, now_iso, parse_value
33
+ from runops.util import (
34
+ hash_file,
35
+ hash_tree,
36
+ load_toml,
37
+ move_noreplace,
38
+ now_iso,
39
+ parse_value,
40
+ tomllib,
41
+ )
42
+
43
+ SHOWN_INPUT_CHANGES = 10
32
44
 
33
45
 
34
46
  @dataclass
@@ -40,6 +52,7 @@ class RunSpec:
40
52
  sweep: str = ""
41
53
  job: dict[str, Any] = field(default_factory=dict)
42
54
  job_changed: list[str] = field(default_factory=list)
55
+ input_changes: list[str] = field(default_factory=list) # set for a run derived from another
43
56
 
44
57
 
45
58
  @dataclass
@@ -118,6 +131,60 @@ def _job_keys(job: dict[str, Any]) -> dict[str, Any]:
118
131
  return {k: v for k, v in job.items() if k in JOB_KEYS}
119
132
 
120
133
 
134
+ def _flat(value: Any, prefix: str = "") -> dict[str, Any]:
135
+ """The leaves of a TOML document under dotted keys, arrays of tables by index."""
136
+ items: Iterable[tuple[Any, Any]]
137
+ if isinstance(value, dict):
138
+ items = value.items()
139
+ elif isinstance(value, list) and any(isinstance(x, dict) for x in value):
140
+ items = enumerate(value)
141
+ else:
142
+ return {prefix: value}
143
+ flat: dict[str, Any] = {}
144
+ for key, item in items:
145
+ flat.update(_flat(item, f"{prefix}.{key}" if prefix else str(key)))
146
+ return flat
147
+
148
+
149
+ def _input_values(input_dir: Path) -> dict[tuple[str, str], Any]:
150
+ """What a run's input files say: ``(file, dotted key)`` for TOML, ``(file, "")`` otherwise."""
151
+ values: dict[tuple[str, str], Any] = {}
152
+ for path in sorted(p for p in input_dir.rglob("*") if p.is_file()):
153
+ rel = path.relative_to(input_dir).as_posix()
154
+ document = None
155
+ if path.suffix == ".toml":
156
+ with contextlib.suppress(tomllib.TOMLDecodeError, UnicodeDecodeError):
157
+ document = load_toml(path)
158
+ if document is None:
159
+ values[(rel, "")] = hash_file(path)
160
+ else:
161
+ values.update({(rel, key): value for key, value in _flat(document).items()})
162
+ return values
163
+
164
+
165
+ def _differing(
166
+ before: dict[tuple[str, str], Any], after: dict[tuple[str, str], Any]
167
+ ) -> list[tuple[str, str]]:
168
+ return sorted(k for k in before.keys() | after.keys() if before.get(k) != after.get(k))
169
+
170
+
171
+ def _input_changes(
172
+ before: dict[tuple[str, str], Any],
173
+ after: dict[tuple[str, str], Any],
174
+ explained: set[str],
175
+ ) -> list[str]:
176
+ """What differs between two runs' inputs, leaving out the keys in *explained*."""
177
+ lines = []
178
+ for key in _differing(before, after):
179
+ rel, name = key
180
+ if not name:
181
+ lines.append(rel)
182
+ elif not any(name == k or name.startswith(f"{k}.") for k in explained):
183
+ old, new = (repr(side[key]) if key in side else "-" for side in (before, after))
184
+ lines.append(f"{name}: {old} -> {new}")
185
+ return lines
186
+
187
+
121
188
  def _diff(label: str, before: dict[str, Any], after: dict[str, Any]) -> str:
122
189
  keys = [k for k in after if before.get(k) != after[k]]
123
190
  keys += [k for k in before if k not in after]
@@ -263,14 +330,15 @@ def create_run(project: Project, request: CreateRequest) -> CreateResult:
263
330
  line = _diff("job vs case", dict(case.job), job)
264
331
  if line:
265
332
  result.changes.append(line)
266
- if source is not None:
267
- recorded = str(source.section("origin").get("case_input_hash", ""))
268
- if recorded and recorded != case.input_hash():
269
- # Inputs come from the case as it is now, not as it was for the source.
270
- result.warnings.append(
271
- f"case {case.name!r} input files changed since {source.id} was created; "
272
- f"{result.manifests[0].id} differs from it by more than the inherited params"
273
- )
333
+ if source is not None and spec.input_changes:
334
+ # Inputs come from the case as it is now, not as it was for the source.
335
+ shown = "; ".join(spec.input_changes[:SHOWN_INPUT_CHANGES])
336
+ hidden = len(spec.input_changes) - SHOWN_INPUT_CHANGES
337
+ more = f"; and {hidden} more" if hidden > 0 else ""
338
+ result.warnings.append(
339
+ f"case {case.name!r} input files changed since {source.id} was created; "
340
+ f"{result.manifests[0].id} differs from it beyond the params in: {shown}{more}"
341
+ )
274
342
  return result
275
343
 
276
344
 
@@ -483,8 +551,13 @@ def _populate(
483
551
  dst = input_dir / rel
484
552
  dst.parent.mkdir(parents=True, exist_ok=True)
485
553
  shutil.copy2(src, dst)
554
+ recorded = str(source.section("origin").get("case_input_hash", "")) if source else ""
555
+ case_moved = bool(recorded) and recorded != case.input_hash()
556
+ rendered: dict[tuple[str, str], Any] = {}
486
557
  try:
487
558
  ctx.adapter.render_inputs(dict(spec.params), stage)
559
+ if case_moved:
560
+ rendered = _input_values(input_dir)
488
561
  if request.continue_run and source is not None:
489
562
  extend = getattr(ctx.adapter, "extend_inputs", None)
490
563
  if not callable(extend):
@@ -497,6 +570,16 @@ def _populate(
497
570
  ) from None
498
571
  except (ValueError, KeyError, OSError) as exc:
499
572
  raise RunError(f"{run_id}: render_inputs failed: {exc}") from exc
573
+ if case_moved and source is not None:
574
+ values = _input_values(input_dir)
575
+ # Keys the user set, or the adapter wrote to continue, are not case changes.
576
+ explained = {
577
+ k.replace("[", ".").replace("]", "") for k in changed_keys(source.params, spec.params)
578
+ }
579
+ explained |= {name for _, name in _differing(rendered, values) if name}
580
+ spec.input_changes = _input_changes(
581
+ _input_values(source.run_dir / "input"), values, explained
582
+ )
500
583
 
501
584
  now = now_iso()
502
585
  run: dict[str, Any] = {
@@ -64,11 +64,9 @@ def project_context(project: Project, *, log_lines: int = 20) -> dict[str, Any]:
64
64
  expires = parse_iso(str(m.run.get("expires_at", "")))
65
65
  if expires is not None and expires <= now:
66
66
  expired += 1
67
- recent = sorted(
68
- (m for m in runs if m.status != RunState.ARCHIVED),
69
- key=lambda m: m.created_at,
70
- reverse=True,
71
- )[:5]
67
+ by_created = sorted(runs, key=lambda m: m.created_at, reverse=True)
68
+ recent = [m for m in by_created if m.status != RunState.ARCHIVED][:5]
69
+ active = [m for m in by_created if m.status in ACTIVE_STATES]
72
70
  log_path = project.root / "LOG.md"
73
71
  log_tail = ""
74
72
  if log_path.is_file():
@@ -88,7 +86,9 @@ def project_context(project: Project, *, log_lines: int = 20) -> dict[str, Any]:
88
86
  for name, sim in project.config.simulators.items()
89
87
  },
90
88
  "status_counts": counts,
91
- "active_runs": sum(1 for m in runs if m.status in ACTIVE_STATES),
89
+ "active_runs": len(active),
90
+ # Every submitted / running run, briefly (no params), so one call shows them all.
91
+ "active": [m.to_summary(project.root, brief=True) for m in active],
92
92
  "expired_scratch": expired,
93
93
  "recent_runs": [m.to_summary(project.root) for m in recent],
94
94
  "notes_files": note_files,
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import json
14
14
  from datetime import datetime, timedelta
15
+ from itertools import pairwise
15
16
  from pathlib import Path
16
17
  from typing import Any
17
18
 
@@ -24,6 +25,7 @@ from runops.util import parse_iso
24
25
 
25
26
  SAMPLES_KEPT = 1000 # per attempt; older samples are dropped when the file doubles
26
27
  RECENT_WINDOW = timedelta(minutes=10)
28
+ STALL_WINDOW = timedelta(hours=1) # no change across this much observed time -> stalled
27
29
  _START_MARK = "runops: start "
28
30
 
29
31
 
@@ -106,11 +108,23 @@ def summarize(
106
108
  reference = earlier[-1] if earlier else (points[0] if len(points) > 1 else origin)
107
109
  rate_attempt = _rate(origin, points[-1])
108
110
  rate_recent = _rate(reference, points[-1]) or rate_attempt
111
+ # When done last moved: the first sample, then every sample that exceeds the one before.
112
+ last_change_at = points[0][0]
113
+ for (_, prev_done), (at, now_done) in pairwise(points):
114
+ if now_done > prev_done:
115
+ last_change_at = at
116
+ stalled = last_at - last_change_at
109
117
  summary: dict[str, Any] = {
110
118
  "done": raw_done,
111
119
  "total": total,
112
120
  "unit": unit,
113
121
  "at": last_at.isoformat(timespec="seconds"),
122
+ "last_change_at": last_change_at.isoformat(timespec="seconds"),
123
+ "stalled_hours": (
124
+ round(stalled.total_seconds() / 3600, 1)
125
+ if not final and stalled >= STALL_WINDOW
126
+ else None
127
+ ),
114
128
  "fraction": round(done / total, 4) if total else None,
115
129
  "rate_recent_per_hour": _round(rate_recent),
116
130
  "rate_attempt_per_hour": _round(rate_attempt),
@@ -157,6 +171,8 @@ def format_progress(progress: dict[str, Any] | None) -> str:
157
171
  parts.append(f"{3600 / rate:.3g} s/{progress['unit']}")
158
172
  if progress.get("eta_hours") is not None:
159
173
  parts.append(f"eta {progress['eta_hours']:.1f}h")
174
+ if progress.get("stalled_hours") is not None:
175
+ parts.append(f"stalled {progress['stalled_hours']:.1f}h")
160
176
  return " ".join(parts)
161
177
 
162
178
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: runops-analyze
3
- description: Analyze or compare run results in a runops project. Use when plotting, investigating a series or sweep, writing an analysis report, or choosing where analysis scripts and outputs belong.
3
+ description: Analyze or compare run results in a runops project. Use when plotting, investigating a series or sweep, writing an analysis or comparison report for a reader, or choosing where analysis scripts and outputs belong.
4
4
  ---
5
5
 
6
6
  # Analyze run results
@@ -30,11 +30,16 @@ description: Analyze or compare run results in a runops project. Use when plotti
30
30
  In the script use `#SBATCH -o logs/%x.%j.out` and `#SBATCH -e logs/%x.%j.err`,
31
31
  the site's resource directives, and `srun` for the payload where required.
32
32
  Keep the working directory at the investigation root so `scripts/` and `out/` resolve.
33
- 5. Update the investigation README, then append the decision and its README link to
33
+ 5. When the result is a report someone will read (a comparison of runs, a dependence,
34
+ "what changed and why"), write it by [report-guide.md](report-guide.md): question and
35
+ answer first, original fields side by side on one scale, each figure explained
36
+ before and after, provenance and checks at the end, as `README.md` plus a
37
+ self-contained `report.html`. [example-report/](example-report/README.md) shows it.
38
+ 6. Update the investigation README, then append the decision and its README link to
34
39
  `LOG.md`. Put reusable formulas, methods and pitfalls in `notes/<topic>.md`.
35
- 6. `runo tidy --only analysis-logs` previews old job logs, retaining the newest pair per
40
+ 7. `runo tidy --only analysis-logs` previews old job logs, retaining the newest pair per
36
41
  job name in each logs directory. Apply with `runo tidy --apply --only analysis-logs`.
37
42
  Archiving a whole group directory also moves its `_analysis/`; a partial archive
38
43
  leaves shared analysis with the remaining runs. `restore RUN` restores only that run.
39
- 7. For an existing project's scattered files, show source → destination paths before
44
+ 8. For an existing project's scattered files, show source → destination paths before
40
45
  moving anything, then fix affected script paths and document links. Keep results.