Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit 99ac6c8

Browse files
toraidlcodex
andcommitted
feat: add workflow stage snapshots and rollback entrypoint
Introduce a dedicated stage snapshot manager (src/app/snapshots.py) to capture and restore workspace checkpoints with indexed metadata. Add CLI controls for snapshot operations: --enable-snapshots, --snapshot-dir, and --rollback-to-snapshot. Workflow now supports snapshot restoration as an explicit early-exit recovery path and captures snapshots after initialization, post-modification, and post-repack stages. Add regression tests for CLI parsing, snapshot capture/restore/list behavior, rollback flow, and snapshot capture invocation order. Update CN/EN README argument tables accordingly. Verification: - .venv/bin/python -m ruff check src/app/cli.py src/app/workflow.py src/app/snapshots.py tests/test_cli.py tests/test_workflow.py tests/test_snapshots.py README.md README_EN.md - .venv/bin/python -m mypy --config-file mypy-curated.ini - .venv/bin/python -m pytest -q tests/test_workflow.py tests/test_snapshots.py - .venv/bin/python -m pytest -q Co-authored-by: OpenAI Codex <noreply@openai.com>
1 parent 8b2a49b commit 99ac6c8

8 files changed

Lines changed: 267 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ sudo python3 main.py --stock <底包路径> --port <移植包路径> --pack-type
155155
| `--skip-preflight` | 跳过预检阶段(不建议) | `false` |
156156
| `--preflight-strict` | 将风险项也视为失败项(用于严格阻断) | `false` |
157157
| `--preflight-report` | 预检 JSON 报告输出路径 | `build/preflight-report.json` |
158+
| `--enable-snapshots` | 在关键阶段保存工作目录快照 | `false` |
159+
| `--snapshot-dir` | 快照目录(未设置时使用 `<work-dir>/snapshots`| `null` |
160+
| `--rollback-to-snapshot` | 从指定快照恢复目标工作目录并退出 | `null` |
158161

159162
---
160163

README_EN.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ sudo python3 main.py --stock <path_to_stock_zip> --port <path_to_port_zip> --pac
157157
| `--skip-preflight` | Skip the preflight phase (not recommended) | `false` |
158158
| `--preflight-strict` | Treat risk findings as failures (strict gating) | `false` |
159159
| `--preflight-report` | Output path for preflight JSON report | `build/preflight-report.json` |
160+
| `--enable-snapshots` | Capture workspace snapshots at key workflow stages | `false` |
161+
| `--snapshot-dir` | Snapshot directory (defaults to `<work-dir>/snapshots`) | `null` |
162+
| `--rollback-to-snapshot` | Restore target workspace from a named snapshot and exit | `null` |
160163

161164
---
162165

src/app/cli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ def build_parser() -> argparse.ArgumentParser:
6161
default="build/preflight-report.json",
6262
help="Path to write preflight JSON report (default: build/preflight-report.json)",
6363
)
64+
parser.add_argument(
65+
"--enable-snapshots",
66+
action="store_true",
67+
help="Capture workflow snapshots at key stages",
68+
)
69+
parser.add_argument(
70+
"--snapshot-dir",
71+
default=None,
72+
help="Snapshot directory (default: <work-dir>/snapshots)",
73+
)
74+
parser.add_argument(
75+
"--rollback-to-snapshot",
76+
default=None,
77+
help="Restore target workspace from the named snapshot and exit",
78+
)
6479
parser.add_argument(
6580
"--phases",
6681
nargs="+",

src/app/snapshots.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Stage snapshot and rollback helpers for workflow checkpoints."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import logging
7+
import shutil
8+
from dataclasses import dataclass
9+
from datetime import datetime, timezone
10+
from pathlib import Path
11+
from typing import Any, cast
12+
13+
14+
@dataclass
15+
class SnapshotRecord:
16+
"""Metadata for a captured stage snapshot."""
17+
18+
name: str
19+
created_at: str
20+
source: str
21+
snapshot_path: str
22+
23+
24+
class StageSnapshotManager:
25+
"""Capture and restore workflow stage snapshots."""
26+
27+
def __init__(self, root_dir: str | Path, logger: logging.Logger) -> None:
28+
self.root_dir = Path(root_dir).resolve()
29+
self.logger = logger
30+
self.root_dir.mkdir(parents=True, exist_ok=True)
31+
self.index_path = self.root_dir / "index.json"
32+
33+
def _load_index(self) -> dict[str, Any]:
34+
if not self.index_path.exists():
35+
return {"snapshots": []}
36+
try:
37+
loaded = json.loads(self.index_path.read_text(encoding="utf-8"))
38+
if isinstance(loaded, dict):
39+
return cast(dict[str, Any], loaded)
40+
self.logger.warning("Snapshot index root is not an object. Reinitializing index.")
41+
return {"snapshots": []}
42+
except json.JSONDecodeError:
43+
self.logger.warning("Snapshot index is invalid JSON. Reinitializing index.")
44+
return {"snapshots": []}
45+
46+
def _save_index(self, data: dict[str, Any]) -> None:
47+
self.index_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
48+
49+
def capture(self, name: str, source_dir: str | Path) -> Path:
50+
"""Capture a snapshot for a workflow stage."""
51+
source = Path(source_dir).resolve()
52+
if not source.exists():
53+
raise FileNotFoundError(f"Snapshot source does not exist: {source}")
54+
55+
snapshot_dir = self.root_dir / name
56+
if snapshot_dir.exists():
57+
shutil.rmtree(snapshot_dir)
58+
shutil.copytree(source, snapshot_dir, dirs_exist_ok=True)
59+
60+
index = self._load_index()
61+
snapshots = [
62+
entry for entry in index.get("snapshots", []) if entry.get("name") != name
63+
]
64+
snapshots.append(
65+
{
66+
"name": name,
67+
"created_at": datetime.now(timezone.utc).isoformat(),
68+
"source": str(source),
69+
"snapshot_path": str(snapshot_dir),
70+
}
71+
)
72+
index["snapshots"] = snapshots
73+
self._save_index(index)
74+
self.logger.info(f"Snapshot captured: {name} -> {snapshot_dir}")
75+
return snapshot_dir
76+
77+
def restore(self, name: str, target_dir: str | Path) -> Path:
78+
"""Restore target directory from a named snapshot."""
79+
snapshot_dir = self.root_dir / name
80+
if not snapshot_dir.exists():
81+
raise FileNotFoundError(f"Snapshot not found: {name}")
82+
83+
target = Path(target_dir).resolve()
84+
if target.exists():
85+
shutil.rmtree(target)
86+
shutil.copytree(snapshot_dir, target, dirs_exist_ok=True)
87+
self.logger.info(f"Snapshot restored: {name} -> {target}")
88+
return target
89+
90+
def list_snapshot_names(self) -> list[str]:
91+
"""Return available snapshot names from index."""
92+
index = self._load_index()
93+
return [entry["name"] for entry in index.get("snapshots", []) if "name" in entry]

src/app/workflow.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from src.app.bootstrap import clean_work_dir, initialize_cache_manager
99
from src.app.preflight import run_preflight, save_preflight_report
10+
from src.app.snapshots import StageSnapshotManager
1011
from src.core.config_loader import load_device_config
1112
from src.core.context import PortingContext
1213
from src.core.modifiers import FirmwareModifier, FrameworkModifier, RomModifier, UnifiedModifier
@@ -148,6 +149,28 @@ def execute_porting(args, logger: logging.Logger) -> int:
148149

149150
resolve_remote_inputs(args, is_official_modify, logger)
150151

152+
work_dir, stock_work_dir, port_work_dir, target_work_dir = resolve_work_paths(args.work_dir)
153+
snapshot_manager = (
154+
StageSnapshotManager(args.snapshot_dir or (work_dir / "snapshots"), logger)
155+
if args.enable_snapshots or args.rollback_to_snapshot
156+
else None
157+
)
158+
159+
if args.rollback_to_snapshot:
160+
if not snapshot_manager:
161+
logger.error("Snapshot manager is not available.")
162+
return 1
163+
try:
164+
snapshot_manager.restore(args.rollback_to_snapshot, target_work_dir)
165+
logger.info(f"Rollback completed from snapshot: {args.rollback_to_snapshot}")
166+
return 0
167+
except FileNotFoundError as exc:
168+
logger.error(str(exc))
169+
available = snapshot_manager.list_snapshot_names()
170+
if available:
171+
logger.info(f"Available snapshots: {', '.join(available)}")
172+
return 2
173+
151174
if not args.skip_preflight:
152175
preflight_report = run_preflight(args, is_official_modify, logger)
153176
report_path = save_preflight_report(preflight_report, args.preflight_report)
@@ -163,8 +186,6 @@ def execute_porting(args, logger: logging.Logger) -> int:
163186
logger.warning("Ignoring --preflight-only because --skip-preflight is set.")
164187
return 0
165188

166-
work_dir, stock_work_dir, port_work_dir, target_work_dir = resolve_work_paths(args.work_dir)
167-
168189
if args.clean:
169190
clean_work_dir(work_dir, logger)
170191

@@ -183,6 +204,8 @@ def execute_porting(args, logger: logging.Logger) -> int:
183204
ctx.cache_manager = cache_manager
184205
ctx.eu_bundle = args.eu_bundle
185206
ctx.initialize_target(clean_existing=True)
207+
if snapshot_manager:
208+
snapshot_manager.capture("phase2_initialized", target_work_dir)
186209

187210
stock_device_code = (
188211
stock.get_prop("ro.product.name_for_attestation")
@@ -205,7 +228,12 @@ def execute_porting(args, logger: logging.Logger) -> int:
205228

206229
phases_to_run = args.phases if args.phases else list(DEFAULT_PHASES)
207230
run_modification_phases(ctx, phases_to_run, logger)
231+
if snapshot_manager:
232+
snapshot_manager.capture("phase3_modified", target_work_dir)
233+
208234
run_repacking(ctx, phases_to_run, pack_type, fs_type, target_work_dir, logger)
235+
if snapshot_manager and ("repack" in phases_to_run or phases_to_run == DEFAULT_PHASES):
236+
snapshot_manager.capture("phase4_repacked", target_work_dir)
209237

210238
logger.info("=" * 70)
211239
logger.info("Porting completed successfully!")

tests/test_cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,21 @@ def test_parse_args_accepts_preflight_flags():
2929
assert args.skip_preflight is False
3030
assert args.preflight_strict is False
3131
assert args.preflight_report == "out/preflight.json"
32+
33+
34+
def test_parse_args_accepts_snapshot_flags():
35+
args = parse_args(
36+
[
37+
"--stock",
38+
"stock.zip",
39+
"--enable-snapshots",
40+
"--snapshot-dir",
41+
"build/snapshots",
42+
"--rollback-to-snapshot",
43+
"phase3_modified",
44+
]
45+
)
46+
47+
assert args.enable_snapshots is True
48+
assert args.snapshot_dir == "build/snapshots"
49+
assert args.rollback_to_snapshot == "phase3_modified"

tests/test_snapshots.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from src.app.snapshots import StageSnapshotManager
5+
6+
7+
def test_snapshot_capture_and_restore(tmp_path: Path):
8+
source = tmp_path / "target"
9+
source.mkdir(parents=True)
10+
(source / "marker.txt").write_text("v1", encoding="utf-8")
11+
12+
manager = StageSnapshotManager(tmp_path / "snapshots", logging.getLogger("test"))
13+
manager.capture("phase2_initialized", source)
14+
15+
(source / "marker.txt").write_text("v2", encoding="utf-8")
16+
manager.restore("phase2_initialized", source)
17+
18+
assert (source / "marker.txt").read_text(encoding="utf-8") == "v1"
19+
20+
21+
def test_snapshot_list_names(tmp_path: Path):
22+
source = tmp_path / "target"
23+
source.mkdir(parents=True)
24+
(source / "a.txt").write_text("ok", encoding="utf-8")
25+
26+
manager = StageSnapshotManager(tmp_path / "snapshots", logging.getLogger("test"))
27+
manager.capture("phase2_initialized", source)
28+
manager.capture("phase3_modified", source)
29+
30+
assert manager.list_snapshot_names() == ["phase2_initialized", "phase3_modified"]

tests/test_workflow.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def make_args(**overrides):
2626
"skip_preflight": False,
2727
"preflight_strict": False,
2828
"preflight_report": "build/preflight-report.json",
29+
"enable_snapshots": False,
30+
"snapshot_dir": None,
31+
"rollback_to_snapshot": None,
2932
}
3033
base.update(overrides)
3134
return Namespace(**base)
@@ -174,3 +177,75 @@ def test_execute_porting_strict_preflight_treats_risks_as_failures():
174177

175178
assert execute_porting(args, logger) == 2
176179
run_preflight_mock.return_value.has_failures.assert_called_once_with(strict=True)
180+
181+
182+
def test_execute_porting_restores_snapshot_and_exits():
183+
logger = MagicMock()
184+
args = make_args(rollback_to_snapshot="phase3_modified")
185+
186+
with (
187+
patch("src.app.workflow.initialize_cache_manager") as bootstrap,
188+
patch("src.app.workflow.log_run_configuration"),
189+
patch("src.app.workflow.OtaToolsManager") as otatools_manager_cls,
190+
patch("src.app.workflow.resolve_remote_inputs"),
191+
patch("src.app.workflow.StageSnapshotManager") as snapshot_manager_cls,
192+
patch("src.app.workflow.resolve_work_paths") as resolve_work_paths,
193+
):
194+
bootstrap.return_value.exit_code = None
195+
bootstrap.return_value.cache_manager = None
196+
otatools_manager_cls.return_value.ensure_otatools.return_value = True
197+
resolve_work_paths.return_value = (
198+
MagicMock(),
199+
MagicMock(),
200+
MagicMock(),
201+
MagicMock(),
202+
)
203+
204+
assert execute_porting(args, logger) == 0
205+
206+
snapshot_manager_cls.return_value.restore.assert_called_once()
207+
208+
209+
def test_execute_porting_captures_snapshots_when_enabled():
210+
logger = MagicMock()
211+
args = make_args(enable_snapshots=True)
212+
213+
with (
214+
patch("src.app.workflow.initialize_cache_manager") as bootstrap,
215+
patch("src.app.workflow.log_run_configuration"),
216+
patch("src.app.workflow.OtaToolsManager") as otatools_manager_cls,
217+
patch("src.app.workflow.resolve_remote_inputs"),
218+
patch("src.app.workflow.run_preflight") as run_preflight_mock,
219+
patch("src.app.workflow.save_preflight_report"),
220+
patch("src.app.workflow.StageSnapshotManager") as snapshot_manager_cls,
221+
patch("src.app.workflow.resolve_work_paths") as resolve_work_paths,
222+
patch("src.app.workflow.RomPackage") as rom_package_cls,
223+
patch("src.app.workflow.PortingContext") as porting_context_cls,
224+
patch("src.app.workflow.load_device_config", return_value={}),
225+
patch("src.app.workflow.determine_pack_settings", return_value=("payload", "erofs")),
226+
patch("src.app.workflow.run_modification_phases"),
227+
patch("src.app.workflow.run_repacking"),
228+
):
229+
bootstrap.return_value.exit_code = None
230+
bootstrap.return_value.cache_manager = None
231+
otatools_manager_cls.return_value.ensure_otatools.return_value = True
232+
run_preflight_mock.return_value.has_failures.return_value = False
233+
resolve_work_paths.return_value = (
234+
MagicMock(),
235+
MagicMock(),
236+
MagicMock(),
237+
MagicMock(),
238+
)
239+
stock = rom_package_cls.return_value
240+
porting_context = porting_context_cls.return_value
241+
porting_context.stock = stock
242+
porting_context.device_config = {}
243+
244+
assert execute_porting(args, logger) == 0
245+
246+
capture_calls = snapshot_manager_cls.return_value.capture.call_args_list
247+
assert [call.args[0] for call in capture_calls] == [
248+
"phase2_initialized",
249+
"phase3_modified",
250+
"phase4_repacked",
251+
]

0 commit comments

Comments
 (0)