feat: add artifact snapshot and restore support
This commit is contained in:
parent
db3ae7cff1
commit
f261f0bf8f
59
engine/artifact_manager.py
Normal file
59
engine/artifact_manager.py
Normal file
@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from difflib import unified_diff
|
||||
|
||||
from engine.models import BaselineSnapshot, TaskSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArtifactManager:
|
||||
task: TaskSpec
|
||||
|
||||
def resolve_paths(self) -> list[Path]:
|
||||
root_dir = self.task.root_dir
|
||||
resolved: set[Path] = set()
|
||||
for pattern in self.task.artifacts.include:
|
||||
for path in root_dir.glob(pattern):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative_path = path.relative_to(root_dir).as_posix()
|
||||
if any(fnmatch(relative_path, exclude) for exclude in self.task.artifacts.exclude):
|
||||
continue
|
||||
resolved.add(path)
|
||||
return sorted(resolved)
|
||||
|
||||
def snapshot(self) -> BaselineSnapshot:
|
||||
file_contents: dict[Path, str] = {}
|
||||
file_hashes: dict[Path, str] = {}
|
||||
for path in self.resolve_paths():
|
||||
content = path.read_text(encoding="utf-8")
|
||||
file_contents[path] = content
|
||||
file_hashes[path] = sha256(content.encode("utf-8")).hexdigest()
|
||||
return BaselineSnapshot(file_contents=file_contents, file_hashes=file_hashes)
|
||||
|
||||
def restore(self, snapshot: BaselineSnapshot) -> None:
|
||||
for path, content in snapshot.file_contents.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
def diff_summary(self, snapshot: BaselineSnapshot) -> str:
|
||||
lines: list[str] = []
|
||||
current_contents = {path: path.read_text(encoding="utf-8") for path in self.resolve_paths()}
|
||||
all_paths = sorted(set(snapshot.file_contents) | set(current_contents))
|
||||
for path in all_paths:
|
||||
before = snapshot.file_contents.get(path, "")
|
||||
after = current_contents.get(path, "")
|
||||
if before == after:
|
||||
continue
|
||||
diff = unified_diff(
|
||||
before.splitlines(keepends=True),
|
||||
after.splitlines(keepends=True),
|
||||
fromfile=f"{path.as_posix()} (before)",
|
||||
tofile=f"{path.as_posix()} (after)",
|
||||
)
|
||||
lines.extend(diff)
|
||||
return "".join(lines)
|
||||
61
tests/test_artifact_manager.py
Normal file
61
tests/test_artifact_manager.py
Normal file
@ -0,0 +1,61 @@
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from engine.artifact_manager import ArtifactManager
|
||||
from engine.models import ArtifactSpec, BaselineSnapshot, TaskSpec
|
||||
from engine.models import BudgetSpec, ConstraintSpec, LoggingSpec, MutationSpec, ObjectiveSpec, PolicySpec, RunnerSpec, ScorerParseSpec, ScorerSpec
|
||||
|
||||
|
||||
def make_task(root_dir: Path) -> TaskSpec:
|
||||
return TaskSpec(
|
||||
id="demo",
|
||||
description="Demo",
|
||||
artifacts=ArtifactSpec(include=["artifacts/*.md"], exclude=["artifacts/ignore.md"], max_files_per_iteration=1),
|
||||
mutation=MutationSpec(mode="direct_edit", allowed_file_types=[".md"], max_changed_lines=20),
|
||||
runner=RunnerSpec(command="python -c \"print('run')\"", cwd=".", timeout_seconds=10),
|
||||
scorer=ScorerSpec(
|
||||
type="command",
|
||||
command="python -c \"import json; print(json.dumps({'score': 1, 'metrics': {'violation_count': 0}}))\"",
|
||||
parse=ScorerParseSpec(format="json", score_field="score", metrics_field="metrics"),
|
||||
),
|
||||
objective=ObjectiveSpec(primary_metric="score", direction="maximize"),
|
||||
constraints=[ConstraintSpec(metric="violation_count", op="<=", value=0)],
|
||||
policy=PolicySpec(keep_if="better_primary", tie_breakers=[], on_failure="discard"),
|
||||
budget=BudgetSpec(max_iterations=1, max_failures=1),
|
||||
logging=LoggingSpec(results_file="work/results.jsonl", candidate_dir="work/candidates"),
|
||||
root_dir=root_dir,
|
||||
)
|
||||
|
||||
|
||||
class ArtifactManagerTest(unittest.TestCase):
|
||||
def test_snapshot_and_restore(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
artifact_dir = root / "artifacts"
|
||||
artifact_dir.mkdir()
|
||||
target = artifact_dir / "sample.md"
|
||||
target.write_text("hello\n", encoding="utf-8")
|
||||
manager = ArtifactManager(make_task(root))
|
||||
snapshot = manager.snapshot()
|
||||
target.write_text("changed\n", encoding="utf-8")
|
||||
manager.restore(snapshot)
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), "hello\n")
|
||||
|
||||
def test_diff_summary_contains_changed_line(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
artifact_dir = root / "artifacts"
|
||||
artifact_dir.mkdir()
|
||||
target = artifact_dir / "sample.md"
|
||||
target.write_text("before\n", encoding="utf-8")
|
||||
manager = ArtifactManager(make_task(root))
|
||||
snapshot = manager.snapshot()
|
||||
target.write_text("after\n", encoding="utf-8")
|
||||
summary = manager.diff_summary(snapshot)
|
||||
self.assertIn("-before", summary)
|
||||
self.assertIn("+after", summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue
Block a user