feat(PLAN68): スナップショットの世代をアカウントグループごとの系列で持つ (#248) - #263
Conversation
- devbase up の自動スナップショットは、起動したグループの系列の最新の世代へ差分を積む。 最小間隔も系列ごとに判定し、ログに系列のグループ名と新しい世代にする理由を出す - rotate は系列ごとに keep 世代を残し、全体の上限 (既定 keep × 3) を超えた分を系列を またいで古い順に消す。各系列の最新の世代は消さない。devbase snapshot rotate に --max-total を足し、--keep はグループごとの数にする - _safe_snap_dir はシンボリックリンクの世代を拒み、包含判定をパスの要素の単位にする。 rotate は消す前に検証し、拒否されたエントリは一覧からだけ外す - 利用者向け文書と CHANGELOG を更新し、実装計画にタスク分解を追記 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add restore confirmation, rotate argument parsing, auto-snapshot failure, and missing or unknown subcommand characterization tests for apply round 1. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default
改修計画 — devbasex/devbase #263
ラウンド 1(実装 codex)R1-001 —
|
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| branch | unit | — | codex / kiro | 採用 | 1 |
なぜ: restore は stdin が tty のとき確認プロンプトを出し、y/yes 以外の入力では mgr.restore を呼ばず 0 を返す安全ガードの分岐を持つが、既存テストは mgr.restore を直接呼ぶ経路だけを固定しており、この確認キャンセル経路 (tty かつ非 y) は固定されていない
手順: 1. SnapshotManager を、restore によって疑似ボリュームの内容が変更される状態付きフェイクに置き換え、name と point を持つ引数で公開 cmd_snapshot を呼べるようにする。
2. TTYありで回答を空文字、n、y、YES にした各ケースと、TTYなしのケースを実行し、終了コードと疑似ボリュームの最終状態を観測する。TTYなしでは input が呼ばれたら失敗する入力境界を置く。
3. 現状の観測値で、拒否時は終了コード0で元の状態を維持し、承認時とTTYなしでは終了コード0で指定した名前・pointに対応する復元状態になることを固定する。
4. 確認プロンプトやキャンセル表示の完全一致、内部ハンドラの呼び出し回数は検証しない。
R1-002 — lib/devbase/cli.py#_create_parser
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| normal | unit | — | claude | 採用 | 1 |
なぜ: snapshot rotate の --max-total は既存テストが SimpleNamespace で cmd_snapshot へ直接渡しており、引数解析(型・既定値 None・--keep の既定 3)を通る経路は固定されていない
手順: 1. cli._create_parser() で ['snapshot', 'rotate', '--keep', '2', '--max-total', '5'] を解析し、subcommand・keep=2・max_total=5(int)を比較する
2. ['snapshot', 'rotate'] を解析し、keep=3・max_total=None を比較する
3. 別名 ['ss', 'rotate', '--max-total', '4'] でも max_total=4 になることを比較する
R1-003 — lib/devbase/commands/container.py#_auto_snapshot
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| error | unit | — | claude | 採用 | 1 |
なぜ: _auto_snapshot は SnapshotManager 側の例外を warning に落としてデプロイを続けるが、この except 経路はどこでも固定されていない。PLAN68 で try の中に series_label と auto_snapshot_target が増えた
手順: 1. root フィクスチャで DEVBASE_ROOT を tmp_path に向け、DEVBASE_ACCOUNT_GROUP に不正なグループ名(ボリュームの解決で SnapshotError になる値)を入れる
2. container._auto_snapshot() を呼び、例外が上がらないことを確認する
3. WARNING のレコードが 1 件出ることと、backups/snapshot.yml が作られていない(または変わっていない)ことを比較する
R1-004 — lib/devbase/commands/container.py#_auto_snapshot
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| normal | integration | — | claude | 採用 | 1 |
なぜ: 自動スナップショットは作成の後に rotate() を既定値で呼ぶが、既存の系列テストはどれも保持数に届かない件数で、作成とローテーションのつなぎ目(新世代の作成で同じ系列の最古だけが消え、他の系列は残る)は固定されていない
手順: 1. write_state で default 3 世代(最新の差分数 10)と with 1 世代を作る
2. DEVBASE_ACCOUNT_GROUP=default で container._auto_snapshot() を呼ぶ
3. snapshot.yml の世代の並びを比較し、default が 3 世代(最古が消え新世代が増えた)・with が 1 世代残ることを確認する
4. 消えた最古の世代のディレクトリが無く、with の世代のディレクトリが残ることを確認する
R1-005 — lib/devbase/commands/snapshot.py#cmd_snapshot
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| error | unit | — | kiro | 採用 | 1 |
なぜ: cmd_snapshot は subcommand が handlers に無いとき error を出して 1 を返す分岐 (if not handler) を持つが、既存の cmd_snapshot テスト (tests/snapshot/test_manager_series.py) は create/restore/copy/delete/rotate の正常系と SnapshotError 経由の 1 だけを固定しており、未知 / 未指定の subcommand で 1 が返る経路は固定されていない
手順: 1. types.SimpleNamespace(subcommand=None) を渡し cmd_snapshot(tmp_path, ns) の戻り値が 1 であることを固定する
2. subcommand を未知の値 (例: 'bogus') にした Namespace でも戻り値が 1 であることを固定する
3. どちらも SnapshotManager の副作用を起こさないため backups ディレクトリを作らず、戻り値のみを比較する
ラウンド 2(実装 kiro)
R2-001 — lib/devbase/cli.py#_create_parser
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| branch | unit | — | kiro | 採用 | 1 |
なぜ: restore サブコマンドは positional name と --point (int, 既定 None) を配線する。固定済みは rotate の引数だけで、restore の解析経路は固定されていない。
手順: 1. _create_parser().parse_args(["snapshot", "restore", "daily", "--point", "2"]) を作る
2. subcommand / name / point の値と point の型を比較する
3. --point 省略時に point が既定 None になる入力も作って比較する
R2-002 — lib/devbase/commands/container.py#_auto_snapshot
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| branch | unit | — | claude | 採用 | 1 |
なぜ: 最小間隔の判定のうち、直近の取得時刻が未来(時計のずれ・他環境からの持ち込み)なら飛ばさずに積むという下限ガードの分岐が固定されていない。固定されているのは間隔内で飛ばす・間隔 0 で必ず積むの 2 本だけである
手順: 1. 既存の root フィクスチャで write_state([('D', 'default', 0)]) を置き、DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES を 60 にする
2. backups/D/full.tar.zst の mtime を現在より 10 分先へずらす
3. container._auto_snapshot() を呼び、backups/D/incr-001.tar.zst が作られることを確かめる
R2-003 — lib/devbase/commands/container.py#cmd_down
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| error | unit | — | codex | 採用 | 1 |
なぜ: tests/commands/test_container_context.py の down テストは接続先の伝搬を検証するが、停止成功後に SnapshotManager.rotate が例外を送出する経路の終了コードは固定していない。現状は例外を吸収し、停止済みの状態で 0 を返す。
手順: 1. DEVBASE_ROOT をテスト用のパスへ設定し、接続先解決と機密注入をスタブ化する。Docker 停止の代替は疑似コンテナの状態を running から stopped へ変えるものにする。
2. SnapshotManager.rotate が RuntimeError を送出するよう依存を差し替え、公開入口 cmd_down() を実行する。
3. 現実装で観測した戻り値 0 と疑似コンテナの stopped 状態を期待値として固定する。内部メソッドの呼び出し回数や警告文の完全一致は検証しない。
R2-004 — lib/devbase/commands/snapshot.py#cmd_snapshot
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| error | unit | — | kiro | 採用 | 1 |
なぜ: cmd_snapshot は handler が SnapshotError を送出したとき try/except で捕えて 1 を返す。固定済みは未指定・未知サブコマンド (handler なしで try 前に return) だけで、この例外経路は固定されていない。
手順: 1. SnapshotManager を、指定サブコマンドの処理で SnapshotError を送出する疑似実装へ差し替える
2. その sub のサブコマンドで cmd_snapshot を呼ぶ
3. 戻り値が 1 であることを比較する (未指定・未知の 1 とは別経路であることを別テストと分けて示す)
R2-005 — lib/devbase/snapshot/manager.py#SnapshotManager.create
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| branch | unit | — | claude | 採用 | 1 |
なぜ: create の分岐のうち固定されているのは新規世代(full)と incr-001 までで、既存世代に snapshot.snar が無いときの full への切り替えと、2 本目以降の差分の番号付け・incremental_count の更新(meta.yml と snapshot.yml)は固定されていない
手順: 1. _run_docker_tar を差し替えた既存のテストダブル(書き込むアーカイブ名を command から拾うもの)で、create(name='g') を 1 回呼んで世代を作る
2. 同じ名前で create(name='g', full=False) を 2 回呼び、incr-001.tar.zst と incr-002.tar.zst が並ぶこと、meta.yml の incremental_count が 2・files に 2 本が載ること、snapshot.yml の該当エントリの incremental_count が 2 であることを比較する
3. 別の世代で snapshot.snar を消してから create(name=..., full=False) を呼び、incr-* が作られず meta.yml の type が full・incremental_count が 0 に戻ることを比較する
4. 期待値は実行して得た現状の値を書く
ラウンド 3(実装 claude)
R3-001 — lib/devbase/commands/container.py#_auto_snapshot
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| long_method | extract_method | major | kiro | 採用 | 1 |
なぜ: 1 メソッドにリモート判定・DEVBASE_ROOT 解決・系列ラベル算出・最小間隔スキップ判定 (last / delta / 下限ガード / ログ)・作成対象の決定と create/差分・rotate が同居し、段階が 5 つ以上通しで並ぶ。間隔スキップ判定は独立して名前が付く塊だが今は本体に埋もれていて単体で追えない。tests/snapshot/test_auto_snapshot_series.py が create/skip/rotate の各経路を通しており、抽出を守れる。
手順: 1. min_interval>0 かつ last が非 None のときの delta 計算・下限ガード・スキップログ・判定を _should_skip_by_interval(label, min_interval, last) -> bool として抽出する
2. _auto_snapshot 側は if self._should... はスキップの return だけを残す
3. datetime/timedelta の import 位置を抽出先に合わせて調整する
4. env -u DEVBASE_ROOT uv run pytest -q tests/snapshot/test_auto_snapshot_series.py で create/skip/rotate 各経路が緑であることを確認する
R3-002 — lib/devbase/snapshot/manager.py#SnapshotManager.series_latest
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| duplication | extract_method | major | claude | 採用 | 1 |
なぜ: 系列の絞り込み self.series_key(self._entry_volumes(s)) == key が last_snapshot_time と series_latest に、世代の古さ (snap.get('created_at', '') or '', index) が series_latest・_rotation_plan の age・rotate の並べ替えキーに、それぞれ別々に書かれている。PLAN68 の『系列』と『同時刻なら snapshot.yml の順』という規則が 3 か所へ散っており、片方だけ直すと系列判定や最新世代の選び方が食い違う
手順: 1. staticmethod _entry_age(entry, index) -> tuple を追加し (entry.get('created_at', '') or '', index) を返す
2. series_latest の age 計算、_rotation_plan 内の age()、rotate の remaining.sort のキーを _entry_age 経由に置き換える
3. _series_entries(volumes) -> list[tuple[int, dict]] を追加し、_entries() のうち series_key が一致するものを (添字, エントリ) で返す
4. series_latest のループと last_snapshot_time の volumes 指定時の内包表記を _series_entries 経由に置き換える
5. tests/snapshot/test_manager_series.py と test_auto_snapshot.py を通す
R3-003 — lib/devbase/snapshot/manager.py#_create_incremental
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| duplication | consolidate_duplication | major | kiro | 採用 | 1 |
なぜ: 差分アーカイブ名の規約 (incr- 接頭辞・.tar.zst 接尾辞・incr-{n:03d} 書式・incr-*.tar.zst グロブ・^incr-(\d+).tar.zst$ 正規表現) が _create_incremental・restore・last_snapshot_time の 3 箇所に別表現で散らばる。片方だけ書式や桁を変えると差分の照合が崩れるが、同じ命名規約に由来し必ず一緒に変わる重複であり共通化の条件を満たす。
手順: 1. INCR_PREFIX='incr-' / ARCHIVE_SUFFIX='.tar.zst' と incr 名を組む incr_archive_name(n) -> str・incr 実体かを判定する is_incr_archive(name) -> bool をモジュール先頭へ置く
2. _create_incremental の f'incr-{next_num:03d}.tar.zst' と glob('incr-*.tar.zst') を helper 経由へ置き換える
3. last_snapshot_time の startswith('incr-')/endswith('.tar.zst') を is_incr_archive へ寄せる
4. restore の glob と正規表現を helper・定数へ寄せる (full.tar.zst 定数も同時に導入)
5. env -u DEVBASE_ROOT uv run pytest -q tests/snapshot で full/incr の作成・列挙・復元経路が緑であることを確認する
R3-004 — lib/devbase/commands/container.py#_ensure_env_files
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| long_method | extract_method | major | codex | 採用 | 1 |
なぜ: 秘密ストアとローカルファイルの存在判定、グループ指定付き env init の子プロセス構築・実行、finally によるストアのキャッシュ解放、プロジェクト .env の作成と成否集約が同居している。子プロセス失敗後もプロジェクト .env 作成は試すという制御と、失敗時にもキャッシュを解放する契約を追うために異なる副作用を同時に読む必要がある。tests/cli/test_up_roundtrips.py は既存設定の再利用・初期化成功後の再取得・group 引数を確認しているが、今回抽出する非ゼロ終了・起動例外・touch 失敗経路の固定が不足している。
手順: 1. tests/cli/test_up_roundtrips.py の既存 fixture を使い、cmd_up 経由で env init の非ゼロ終了・起動例外、およびプロジェクト .env 作成失敗を現状固定する。終了値、後続デプロイの未実行、共通側の失敗後も行われるプロジェクト .env 作成、初期化失敗後の runtime.resolve によるキャッシュ再取得を観測する。
2. 子プロセスの引数・環境構築と実行、既存ログ、例外処理、finally の release_store を _initialize_global_env(devbase_root, group) -> bool に一体で抽出する。キャッシュ解放のタイミングと例外の扱いを維持する。
3. プロジェクト .env の touch と既存ログ・例外処理を _create_project_env(project_env) -> bool に抽出する。呼び出し元には存在判定と順序制御を残し、各処理結果を実行後に集約して短絡評価による処理省略を防ぐ。
4. 既存の成功系と追加の現状固定テスト、指定の全体 pytest を実行する。見積220行は本体の削除・追加約110行、呼び出し側・引数・import 調整約20行、現状固定テスト約90行を含む。
R3-005 — lib/devbase/commands/snapshot.py#_snapshot_list
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| magic_value | introduce_named_constant | minor | kiro | 採用 | 1 |
なぜ: 旧世代の対象ボリューム欠落時の既定として 'devbase_home_ubuntu' を文字列直書きしている。同じ値は volume/manager.py の HOME_UBUNTU_VOLUME 定数として既に定義され snapshot/manager.py 経由でも参照可能で、共通ボリューム名を変えるときこの直書きだけ取り残される。
手順: 1. snapshot/manager.py 経由 (または volume.manager) から HOME_UBUNTU_VOLUME を import する
2. _snapshot_list の 'devbase_home_ubuntu' フォールバックを HOME_UBUNTU_VOLUME へ置き換える
3. 値が同一 (定数の定義と一致) であることを確認し、env -u DEVBASE_ROOT uv run pytest -q tests/commands/test_snapshot.py tests/commands/test_snapshot_dispatch.py で緑を確認する
ラウンド 4(実装 codex)
R4-001 — lib/devbase/snapshot/manager.py#SnapshotManager.restore
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| duplication | consolidate_duplication | major | kiro | 採用 | 1 |
なぜ: full と各 incr の展開で 'zstd -d /backup/{archive} -c | tar --listed-incremental=/dev/null -xf - -C /target' というコマンド文字列を 2 箇所で組み立てており、書式を片方だけ変えると復元経路が崩れる。同じ復元規約に由来する重複。
手順: 1. 展開コマンドを組み立てる補助 _incremental_extract_command(archive) を追加する
2. full の呼び出しを self.clear_command(volumes) + self._incremental_extract_command(FULL_ARCHIVE) に置き換える
3. incr ループの呼び出しを self._incremental_extract_command(incr.name) に置き換える
4. tests/snapshot/test_restore_incremental.py の restore 経路テストで退行がないことを確認する
R4-002 — lib/devbase/snapshot/manager.py#SnapshotManager.rotate
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| magic_value | introduce_named_constant | minor | claude | 取り消し | 0 |
なぜ: 全体の上限の既定 max_total = keep * 3 の係数 3 が本体に直書きされている。DEFAULT_MAX_GENERATIONS(同じく 3)と値が偶然一致して見分けにくく、docstring・CLI の help('3 x --keep')と揃えて変える必要がある業務ルールなのに名前が無い
手順: 1. モジュール定数 DEFAULT_TOTAL_GENERATIONS_FACTOR = 3 を DEFAULT_MAX_GENERATIONS の隣に導入し、コメントで「系列ごとの keep に掛けて全体の上限の既定にする」と書く
2. rotate の max_total = keep * 3 を keep * DEFAULT_TOTAL_GENERATIONS_FACTOR に置き換える
3. 既存の tests/snapshot/test_manager_series.py::test_cli_rotate_without_max_total_uses_keep_times_three を含む tests/snapshot を実行する
R4-003 — lib/devbase/snapshot/manager.py#SnapshotManager._rotation_plan
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| magic_value | introduce_named_constant | minor | claude | 採用 | 1 |
なぜ: 削除理由を None(系列ごとの保持)と文字列 'total'(全体の上限)で表し、_rotation_plan が生成し rotate が 'reason is not None' / 'reason is None' で 2 箇所読み分けている。'total' という文字列の意味と None の意味が docstring にしか無く、生成側と読み手側の対応が名前で追えない
手順: 1. None と 'total' の代わりに、モジュール定数 _REASON_PER_SERIES = 'series' と _REASON_TOTAL = 'total' を導入する(rotate 外へ値は出ないため値の変更は振る舞いに影響しない)
2. _rotation_plan の (i, None) を (i, _REASON_PER_SERIES)、(oldest.pop(0), 'total') を (oldest.pop(0), _REASON_TOTAL) に置き換え、docstring を定数名で書き直す
3. rotate の reason is not None を reason == _REASON_TOTAL、reason is None を reason == _REASON_PER_SERIES に置き換える
4. tests/snapshot/test_manager_series.py のローテーション系(ログ文言の検証を含む)を実行する
R4-004 — lib/devbase/snapshot/manager.py#SnapshotManager.clear_command
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| duplication | consolidate_duplication | minor | kiro | 採用 | 1 |
なぜ: 「サブディレクトリ名 sub をコンテナ内パスへ写す」規約 (sub があれば {root}/{sub}、空文字なら root) が volume_mount_args と clear_command の両方に別々に書かれており、旧レイアウト (空文字) の扱いを片方だけ直すと不整合になる。
手順: 1. sub とマウントルートからコンテナ内パスを返す静的補助 _container_path(root, sub) を追加する
2. volume_mount_args の target 生成をこの補助へ置き換える
3. clear_command の roots 生成をこの補助へ置き換える
4. restore 経路 (test_restore_incremental) と test_manager_volumes で退行がないことを確認する
R4-005 — lib/devbase/snapshot/manager.py#SnapshotManager.should_start_new_generation
| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット |
|---|---|---|---|---|---|
| dead_code | remove_dead_code | minor | claude | 検証中 | 1 |
なぜ: PLAN68 で _auto_snapshot は auto_snapshot_target を直接呼ぶようになり、should_start_new_generation は本番コードから呼ばれなくなった(lib/ 内の呼び出し 0 件、tests/snapshot/test_manager_series.py と test_manager_volumes.py の 4 箇所だけが使う)。auto_snapshot_target(...) is None を包むだけの別名が残り、読み手が現役の判定入口だと誤解する
手順: 1. tests/snapshot/test_manager_volumes.py の 3 箇所と tests/snapshot/test_manager_series.py の 1 箇所で、mgr.should_start_new_generation() を (mgr.auto_snapshot_target() is None) に書き換え、同じ真偽を検証する
2. テストを実行し通ることを確かめる
3. SnapshotManager.should_start_new_generation を削除する
4. grep で lib/ と tests/ に参照が残っていないことを確かめ、全体テストを実行する
見送った項目
| ラウンド | 対象 | 兆候・経路 | 理由 |
|---|---|---|---|
| 1 | lib/devbase/snapshot/manager.py#SnapshotManager.auto_snapshot_target |
branch | 1 ラウンドの採用上限 5 件を超えた |
| 1 | lib/devbase/snapshot/manager.py#SnapshotManager.copy |
normal | 1 ラウンドの採用上限 5 件を超えた |
| 1 | lib/devbase/snapshot/manager.py#SnapshotManager.restore |
branch | 1 ラウンドの採用上限 5 件を超えた |
| 1 | lib/devbase/snapshot/manager.py#SnapshotManager.rotate |
branch | 1 ラウンドの採用上限 5 件を超えた |
| 2 | lib/devbase/snapshot/manager.py#SnapshotManager.delete |
normal | 1 ラウンドの採用上限 5 件を超えた |
| 2 | lib/devbase/snapshot/manager.py#SnapshotManager.list |
boundary | 1 ラウンドの採用上限 5 件を超えた |
| 2 | lib/devbase/snapshot/manager.py#SnapshotManager.list |
normal | 1 ラウンドの採用上限 5 件を超えた |
| 2 | lib/devbase/snapshot/manager.py#SnapshotManager.restore |
error | 1 ラウンドの採用上限 5 件を超えた |
| 3 | lib/devbase/commands/snapshot.py#cmd_snapshot |
scattered_config | 1 ラウンドの採用上限 5 件を超えた |
| 3 | lib/devbase/snapshot/manager.py#SnapshotManager._load_snap_meta |
duplication | 1 ラウンドの採用上限 5 件を超えた |
| 3 | lib/devbase/snapshot/manager.py#SnapshotManager.last_snapshot_time |
magic_value | 1 ラウンドの採用上限 5 件を超えた |
| 4 | lib/devbase/commands/snapshot.py#_snapshot_list |
magic_value | 1 ラウンドの採用上限 5 件を超えた |
| 4 | lib/devbase/snapshot/manager.py#SnapshotManager.auto_snapshot_target |
duplication | 1 ラウンドの採用上限 5 件を超えた |
| 4 | lib/devbase/snapshot/manager.py#SnapshotManager.rotate |
magic_value | 適用結果に項目がありません: R4-002(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) |
…shot 自動スナップショットの「新世代の作成 → 既定 rotate()」のつなぎ目を現状固定する。 default 3 世代 (最新の差分数 10) と with 1 世代から default で呼ぶと、default は 新世代が積まれて 4 世代 → 最古が落ちて 3 世代に戻り、with の 1 世代は残る。 消えた最古のディレクトリが無く、with の世代のディレクトリが残ることも確認する。 対象コードは変更しない。 Item-Id: R1-004 Round: 1 Impl-Runtime: kiro Impl-Model: default
…guard, down rotate failure, dispatch error, create incrementals - cli: restore の name / --point (int, 既定 None) の解析を固定 - _auto_snapshot: 直近の取得時刻が未来なら間隔内でも差分を積む下限ガードを固定 - cmd_down: 停止後に rotate が例外を送出しても 0 を返し停止済みのままであることを固定 - cmd_snapshot: handler の SnapshotError を捕えて 1 を返す経路を固定 - SnapshotManager.create: incr-002 の番号付けと incremental_count の更新、 snapshot.snar 欠落時の full への切り替えを固定 Item-Id: R2-001 Round: 2 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Extract the automatic snapshot interval check and share series filtering and generation age ordering. Reuse HOME_UBUNTU_VOLUME for legacy snapshot listings without changing behavior. Item-Id: R3-001 Round: 3 Impl-Runtime: codex Impl-Model: default
… names & _ensure_env_files
R3-003: 差分アーカイブの命名規約 (incr- 接頭辞・.tar.zst 接尾辞・incr-{n:03d}
書式・incr-*.tar.zst グロブ・^incr-(\d+)\.tar\.zst$ 正規表現) が
_create_incremental・restore・last_snapshot_time に散らばっていたのを、
モジュール先頭の定数 (INCR_PREFIX / ARCHIVE_SUFFIX / FULL_ARCHIVE / INCR_GLOB /
_INCR_ARCHIVE_RE) と helper (incr_archive_name / is_incr_archive) へ寄せた。
R3-004: _ensure_env_files から、子プロセスの env init 実行・ログ・例外処理・
finally の release_store を _initialize_global_env(devbase_root, group) へ、
project .env の touch・ログ・例外処理を _create_project_env(project_env) へ
抽出した。呼び出し元には存在判定と順序制御・結果の集約を残す。抽出経路
(非ゼロ終了・起動例外・touch 失敗) の現状固定テストを追加した。
振る舞いは不変。tests/snapshot tests/cli tests/commands は 1561 passed / 1 skipped。
Item-Id: R3-003
Round: 3
Impl-Runtime: kiro
Impl-Model: default
…napshotManager.restore full と各 incr の展開で重複していた zstd | tar --listed-incremental=/dev/null のコマンド組み立てを _incremental_extract_command(archive) に集約した。 生成されるコマンド文字列は変わらない。 Item-Id: R4-001 Round: 4 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…SnapshotManager._rotation_plan ローテーションの削除理由を None / 'total' からモジュール定数 _REASON_PER_SERIES / _REASON_TOTAL に置き換え、_rotation_plan の生成側と rotate の読み分けを名前で対応付ける。値は rotate の外へ出ないため振る舞いは不変。 Item-Id: R4-003 Round: 4 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…napshotManager.clear_command
サブディレクトリ名をコンテナ内パスへ写す規約(sub があれば {root}/{sub}、空文字なら root)が
volume_mount_args と clear_command に別々に書かれていたため、静的補助 _container_path へ集約した。
Item-Id: R4-004
Round: 4
Impl-Runtime: claude
Impl-Model: default
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Remove the unused generation predicate wrapper and preserve its boolean assertions through auto_snapshot_target. Item-Id: R4-005 Round: 4 Impl-Runtime: codex Impl-Model: default
cross-refactoring の提案のうち、この PR が変えていない既存の関数に向いたものを戻す。 - R3-003/R3-004 (c2d75e0): restore のアーカイブ名の集約と _ensure_env_files の分割、その現状固定テスト - R3-005 (5fc8239 の commands/snapshot.py 分): _snapshot_list の定数化 - R4-001 (5ed1d61): restore の展開コマンドの集約 - R4-004 (2b63b89): clear_command の重複の集約 範囲外の提案は別の課題として起票する。 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 1 | codex | APPROVE
追加の修正指摘はありません。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 1 | agy | APPROVE
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 2 | agy | REQUEST_CHANGES
SnapshotManager._entry_age() において snapshot.yml 由来の created_at の型が datetime.datetime と str(または未設定時の空文字)で混在した場合に TypeError でクラッシュする不具合への修正が必要です。PLAN68 の受け入れ条件(系列ごとの世代管理、最新世代の保持、_safe_snap_dir によるパス検証、既存 restore の不変性など)は適切に実装されています。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 2 | kiro | APPROVE
PLAN68 の系列ベース世代管理の実装をレビューした。受け入れ条件 1〜28 (差分の積み先・系列ごとの最小間隔・系列ごとの保持と全体の上限・各系列の最新を消さない・_safe_snap_dir のリンク/包含判定・restore の不変性) は manager.py の実装とテストで満たされており、docs 4 本と CHANGELOG も条件どおりに一致している。全体テスト (env -u DEVBASE_ROOT uv run pytest -q) は 3080 件緑 (受け入れ条件 23)。範囲外の構造改善 (R3-003/R3-004/R3-005/R4-001/R4-004) は a81af47 で PR から戻され、既存コメントの改修計画と整合している。修正を要する指摘は見つからなかった。
snapshot.yml を手で書いて created_at に引用符が無いと YAML が datetime で返し、 文字列のエントリと比べて series_latest / rotate が TypeError で落ちていた。 _entry_age で isoformat の文字列に揃える。 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
🔧 /ndf:fix サマリ | round 2 | commit b58f9ee対応件数: critical=0 / major=0 / minor=1(合計 1 件) |
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 3 | claude | REQUEST_CHANGES
受け入れ条件 1〜28 と設計に対する実装・文書はおおむね一致。場所が不正な世代(シンボリックリンク・../ を含む名前)が系列の最新にあると、その系列の自動スナップショットが恒久的に失敗し続ける経路が 1 件残る。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 3 | kiro | APPROVE
PLAN68(スナップショットの系列化)の実装は受け入れ条件 1〜28 の追跡した範囲(rotate の系列ごと保持・全体上限・各系列の最新を残す動作、_safe_snap_dir のシンボリックリンク/is_relative_to 検証、restore の不変性、_entry_age の未引用 created_at の文字列統一)と一致し、docs(05-snapshot.md / 02-project.md / container-operations.md / snapshot-guide.md / CHANGELOG)も実装と整合していた。tests/ 全体 3082 件が green で回帰なし。範囲外の構造改善(R4-001 restore / R4-004 clear_command / R3-004 _ensure_env_files / R3-005 _snapshot_list 定数化)は a81af47 で正しく revert され #265/#266 へ回されており、この PR の欠陥ではない。修正を要する指摘は検出しなかった。
系列の最新のエントリがシンボリックリンクか不正な名前 (../outside など) のとき、 auto_snapshot_target がその名前を返し create が _safe_snap_dir で落ちていた。 rotate は系列の最新を消さないため、devbase up のたびに同じ失敗を繰り返す。 積み先を返す前に _safe_snap_dir を通し、拒否されたら WARNING を出して None を返す。 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
🔧 /ndf:fix サマリ | commit f557461対応件数: critical=0 / major=0 / minor=0(合計 0 件) |
PLAN68 の要求と設計を issues/old/ へ移し、系列の識別・差分の積み先・ 系列ごとの最小間隔・保持と全体の上限・世代の場所の検証・ログの文言・ テスト観点を snapshot-series.md として確定仕様にした。クラス図は実装に 合わせて直した。スナップショットガイドの「系列」から新しい仕様へリンクする。 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
確定仕様化で実装と照合した際に見つけた rotate の戻り値と CLI の文言の食い違い(危険な世代を一覧から外しただけでも「ローテーション不要です」と出る)は範囲外と判断し、#269 として残した。 |
Summary
アカウントグループを行き来しても、同じグループのスナップショットの履歴が短くならず、
full.tar.zstを取り直さないようにする。世代を対象ボリュームの組(グループ)ごとの系列で持つ。Closes #248
変更
lib/devbase/snapshot/manager.pyseries_key/series_label/_entry_volumes/series_latest)、積み先の判定auto_snapshot_target(should_start_new_generationはそれを包むだけ)、last_snapshot_time(volumes)、rotate(keep, max_total)(系列ごと + 全体の上限keep × 3、各系列の最新は消さない、消す前に_safe_snap_dirで検証)、_safe_snap_dirのリンク拒否とパス要素単位の包含判定lib/devbase/commands/container.py_auto_snapshotが最小間隔と積み先を系列で判定し、ログにグループ名を出す(list()[-1]を使わない)lib/devbase/cli.py/commands/snapshot.pydevbase snapshot rotate --max-total Mを追加、--keepはグループごと。振り分けはgetattr(args, 'max_total', None)lib/devbase/tui/actions_snapshot.pysnapshot-guide.md、cli-reference/05-snapshot.md、cli-reference/02-project.md、container-operations.md、troubleshooting.md(rotate --keep 2の例の意味)、CHANGELOG.md(Changed / Fixed)tests/snapshot/test_manager_series.py・tests/snapshot/test_auto_snapshot_series.pyを新設、tests/cli/tui/test_actions_snapshot.pyの rotate の問いを固定受け入れ条件との対応
test_manager_series.pyの積み先のテスト、test_auto_snapshot_series.py::test_returning_group_appends_to_its_generation/test_group_without_generation_creates_new、既存のtest_manager_volumes.pytest_last_snapshot_time_per_series、test_min_interval_is_per_series、test_zero_interval_never_skipstest_manager_series.pyの保持のテストtest_cli_rotate_passes_max_total、test_cli_rotate_without_max_total_uses_keep_times_three、test_actions_snapshot.py::test_run_operation_rotate_collects_keeptest_auto_snapshot_series.pyの caplogtest_restore_incremental.py/test_manager_volumes.pyを変更なしで通過test_manager_series.pyの場所の検証のテストTest plan
DEVBASE_ROOT=<scratch> uv run --locked pytest tests/ -q— exit=0(3061 passed in 151.89s、実装の持ち場 e965f1d)env -u DEVBASE_ROOT uv run --locked pytest tests/ -q— exit=0(3086 passed in 116.83s)。tests/snapshot tests/cli/tui— exit=0(396 passed)。uvx ruff check --select=E9,F63,F7,F82 lib— exit=0。CI(Pytest 3.10 / 3.13・Ruff・ShellCheck・構文検査)すべて successtests/snapshotで Docker を使うtest_restore_incremental.py::test_a_generation_with_swapped_directories_restores_completelyが 1 度落ち、その後の 4 回(そのファイルだけ 2 回・範囲 1 回・全体 1 回)は通った(Docker Desktop の再起動の直後。この変更はrestoreに触れていない)uvx ruff check --select=E9,F63,F7,F82 lib— exit=0(All checks passed!)DEVBASE_ROOTと一時のボリューム 3 本(共通 1 本・グループ 2 本。共通ボリューム名だけ一時の名前へ差し替え)で、実際のdevbase-snapshot(tar (GNU tar) 1.35)を使って次を通した。スクリプトは PR のコードのSnapshotManagerと_auto_snapshotを直接呼ぶ。e965f1d(2026-09-24 12:22)と f557461(14:16)の 2 回ともALL OKDEVBASE_ACCOUNT_GROUP=with・DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=0で_auto_snapshot()を呼ぶと with の世代 B(full)ができる(ログ「グループ plan68twith の世代がまだ無いため、新しい世代を作成します」)_auto_snapshot()を呼ぶと、A へincr-001を積む(ログ「スナップショットを差分更新中: (グループ plan68tdef)」)。世代は 2 つのまま、「対象ボリュームの構成が変わったため」の行は出ないincr-002を積んだ後、ボリュームを壊してrestore(A)→ 共通・default の中身が最後の差分の時点と一致(md5 の一覧で比較)。restore(B)→ with の時点へ戻る。restore(A, point=1)→ incr-001 の時点に戻るbackups/には触れていないdevbase up(DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=0)し、20260920-212546と20260923-081407にそれぞれincr-001が積まれ世代が 3 つのままであることの確認。利用者のコンテナを起こすことになるため、検査の持ち場では行わなかった(ディスク満杯で Docker Desktop を再起動した後で、コンテナはすべて止まっている)。配布後の確認(リリース後テスト)へ回す。事前に、PR のコードで実環境のsnapshot.ymlの写しに対して積み先とローテーションの計画を計算し、with →20260920-212546、default →20260923-081407、ローテーションで消す世代 0 を確かめた検査の持ち場で入った変更
_auto_snapshotの間隔判定の切り出し・系列の並べ替えの共通化・_rotation_planの定数化・should_start_new_generationの削除。改修計画: feat(PLAN68): スナップショットの世代をアカウントグループごとの系列で持つ (#248) #263 (comment) 。PR の差分に無い既存の関数へ向いた提案は戻した(a81af47、commands/container.py の _ensure_env_files が長く、env の作成の各段が 1 関数に入っている #265・snapshot/manager.py のアーカイブ名と展開コマンドの組み立てが restore・作成・clear_command に散らばっている #266 へ起票)created_atが引用符なしの日時でも系列の並べ替えが落ちないようにした(b58f9ee)。系列の最新が扱えない世代(リンク・../outside)なら新しい世代を作るようにした(f557461)範囲外
snapshot listが引用符なしのcreated_atで落ちる、変更前から)snapshot-guide.mdの名前付き世代の記述。「運用のベストプラクティス」2 はそのまま残した)確定仕様化(2b06856)
docs/specifications/snapshot-series.mdissues/PLAN68_snapshot-series*.md→issues/old/docs/user/snapshot-guide.mdenv -u DEVBASE_ROOT uv run pytest -qexit=0、CI(2b06856)すべて success。🤖 Generated with Claude Code