Skip to content

Commit 623b521

Browse files
committed
feat(examples): add transactional prompt writeback
1 parent 7250d91 commit 623b521

2 files changed

Lines changed: 462 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import os
5+
import sys
6+
import tempfile
7+
from collections.abc import Iterator
8+
from contextlib import contextmanager
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
12+
from .schemas import WritebackResult
13+
14+
15+
class ConcurrentPromptUpdateError(RuntimeError):
16+
"""Raised when source prompts no longer match their captured snapshot."""
17+
18+
19+
@dataclass(frozen=True)
20+
class PromptFileSnapshot:
21+
"""Byte-for-byte snapshot of one source prompt file."""
22+
23+
name: str
24+
path: Path
25+
content: bytes
26+
sha256: str
27+
28+
29+
@dataclass(frozen=True)
30+
class PromptSnapshot:
31+
"""Ordered bundle of source prompt snapshots keyed by prompt name."""
32+
33+
files: dict[str, PromptFileSnapshot]
34+
35+
def hashes(self) -> dict[str, str]:
36+
"""Return a fresh name-to-hash mapping for this snapshot."""
37+
38+
return {name: prompt_file.sha256 for name, prompt_file in self.files.items()}
39+
40+
41+
def _hash_bytes(content: bytes) -> str:
42+
return hashlib.sha256(content).hexdigest()
43+
44+
45+
def snapshot_prompt_files(paths: dict[str, str | Path]) -> PromptSnapshot:
46+
"""Read source prompt files and capture their exact bytes and hashes."""
47+
48+
files: dict[str, PromptFileSnapshot] = {}
49+
for name, raw_path in paths.items():
50+
path = Path(raw_path)
51+
content = path.read_bytes()
52+
files[name] = PromptFileSnapshot(
53+
name=name,
54+
path=path,
55+
content=content,
56+
sha256=_hash_bytes(content),
57+
)
58+
return PromptSnapshot(files=files)
59+
60+
61+
def _current_hashes(snapshot: PromptSnapshot) -> dict[str, str]:
62+
return {name: _hash_bytes(prompt_file.path.read_bytes()) for name, prompt_file in snapshot.files.items()}
63+
64+
65+
def _atomic_replace_bytes(path: Path, content: bytes) -> None:
66+
"""Replace one file atomically after durably flushing a same-directory temp."""
67+
68+
fd, temp_name = tempfile.mkstemp(
69+
dir=path.parent,
70+
prefix=f".{path.name}.",
71+
suffix=".tmp",
72+
)
73+
temp_path = Path(temp_name)
74+
try:
75+
temp_file = os.fdopen(fd, "wb")
76+
fd = -1
77+
with temp_file:
78+
temp_file.write(content)
79+
temp_file.flush()
80+
os.fsync(temp_file.fileno())
81+
os.replace(temp_path, path)
82+
finally:
83+
active_exception = sys.exc_info()[0] is not None
84+
cleanup_error: OSError | None = None
85+
if fd >= 0:
86+
try:
87+
os.close(fd)
88+
except OSError as error:
89+
cleanup_error = error
90+
try:
91+
temp_path.unlink()
92+
except FileNotFoundError:
93+
pass
94+
except OSError as error:
95+
if cleanup_error is None:
96+
cleanup_error = error
97+
if cleanup_error is not None and not active_exception:
98+
raise cleanup_error
99+
100+
101+
def _restore_snapshot(snapshot: PromptSnapshot) -> list[str]:
102+
failures: list[str] = []
103+
for name, prompt_file in snapshot.files.items():
104+
try:
105+
_atomic_replace_bytes(prompt_file.path, prompt_file.content)
106+
except OSError as error:
107+
failures.append(f"{name}: {error}")
108+
return failures
109+
110+
111+
def _encode_prompt_bundle(
112+
snapshot: PromptSnapshot,
113+
prompts: dict[str, str],
114+
) -> dict[str, bytes]:
115+
missing = [name for name in snapshot.files if name not in prompts]
116+
if missing:
117+
raise ValueError(f"missing prompts for snapshot files: {', '.join(missing)}")
118+
return {name: prompts[name].encode("utf-8") for name in snapshot.files}
119+
120+
121+
def _restoration_error(snapshot: PromptSnapshot, failures: list[str]) -> str | None:
122+
details: list[str] = []
123+
if failures:
124+
details.append(f"restore failures: {'; '.join(failures)}")
125+
126+
try:
127+
current_hashes = _current_hashes(snapshot)
128+
except OSError as error:
129+
details.append(f"restore verification failed: {error}")
130+
else:
131+
expected_hashes = snapshot.hashes()
132+
mismatched = [name for name, expected_hash in expected_hashes.items() if current_hashes[name] != expected_hash]
133+
if mismatched:
134+
details.append(f"restored hashes differ for: {', '.join(mismatched)}")
135+
136+
if not details:
137+
return None
138+
return "; ".join(details)
139+
140+
141+
@contextmanager
142+
def temporary_prompt_bundle(
143+
snapshot: PromptSnapshot,
144+
prompts: dict[str, str],
145+
) -> Iterator[None]:
146+
"""Temporarily install candidate prompts and always restore the snapshot."""
147+
148+
encoded_prompts = _encode_prompt_bundle(snapshot, prompts)
149+
try:
150+
for name, prompt_file in snapshot.files.items():
151+
_atomic_replace_bytes(prompt_file.path, encoded_prompts[name])
152+
yield
153+
finally:
154+
failures = _restore_snapshot(snapshot)
155+
restoration_error = _restoration_error(snapshot, failures)
156+
if restoration_error is not None:
157+
raise RuntimeError(f"failed to restore prompt snapshot: {restoration_error}")
158+
159+
160+
def commit_prompt_bundle(
161+
snapshot: PromptSnapshot,
162+
prompts: dict[str, str],
163+
) -> WritebackResult:
164+
"""Apply a complete prompt bundle with CAS and compensating rollback."""
165+
166+
before_hashes = _current_hashes(snapshot)
167+
expected_hashes = snapshot.hashes()
168+
if before_hashes != expected_hashes:
169+
changed = [name for name, expected_hash in expected_hashes.items() if before_hashes[name] != expected_hash]
170+
raise ConcurrentPromptUpdateError(f"source prompt files changed since snapshot: {', '.join(changed)}")
171+
172+
encoded_prompts = _encode_prompt_bundle(snapshot, prompts)
173+
try:
174+
for name, prompt_file in snapshot.files.items():
175+
_atomic_replace_bytes(prompt_file.path, encoded_prompts[name])
176+
applied_hashes = _current_hashes(snapshot)
177+
except OSError as error:
178+
rollback_failures = _restore_snapshot(snapshot)
179+
try:
180+
after_hashes = _current_hashes(snapshot)
181+
except OSError as hash_error:
182+
after_hashes = {}
183+
rollback_failures.append(f"hash verification: {hash_error}")
184+
else:
185+
rollback_failures.extend(
186+
f"{name}: restored hash differs from snapshot"
187+
for name, expected_hash in expected_hashes.items()
188+
if after_hashes[name] != expected_hash
189+
)
190+
191+
error_message = f"prompt commit failed: {error}"
192+
if rollback_failures:
193+
error_message += f"; rollback failures: {'; '.join(rollback_failures)}"
194+
return WritebackResult(
195+
status="rollback_failed" if rollback_failures else "rolled_back",
196+
before_hashes=before_hashes,
197+
after_hashes=after_hashes,
198+
error=error_message,
199+
)
200+
201+
return WritebackResult(
202+
status="applied",
203+
before_hashes=before_hashes,
204+
after_hashes=applied_hashes,
205+
)

0 commit comments

Comments
 (0)