fix: make join approval atomic
This commit is contained in:
+57
-13
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
@@ -70,6 +72,14 @@ class DecisionRecord(BaseModel):
|
||||
phase: int | None = None
|
||||
|
||||
|
||||
class DecisionInput(BaseModel):
|
||||
type: DecisionType
|
||||
subject: str
|
||||
detail: str = ""
|
||||
rationale: str = ""
|
||||
retracts: int | None = None
|
||||
|
||||
|
||||
def list_decisions(session_dir: Path) -> list[DecisionRecord]:
|
||||
path = session_dir / DECISIONS_FILE
|
||||
if not path.exists():
|
||||
@@ -90,24 +100,58 @@ def append_decision(
|
||||
rationale: str = "",
|
||||
retracts: int | None = None,
|
||||
) -> DecisionRecord:
|
||||
return append_decisions(
|
||||
session_dir,
|
||||
[{
|
||||
"type": type,
|
||||
"subject": subject,
|
||||
"detail": detail,
|
||||
"rationale": rationale,
|
||||
"retracts": retracts,
|
||||
}],
|
||||
)[0]
|
||||
|
||||
|
||||
def append_decisions(
|
||||
session_dir: Path,
|
||||
decisions: list[DecisionInput | dict],
|
||||
) -> list[DecisionRecord]:
|
||||
"""Validate and append a decision set through one atomic file replacement."""
|
||||
inputs = [DecisionInput.model_validate(decision) for decision in decisions]
|
||||
if not inputs:
|
||||
return []
|
||||
|
||||
# Fase corrente PRIMA dell'append (high-water-mark D15). Import lazy: phase.py
|
||||
# importa decisions.py (ciclo). Per i marker di fase (subject "phase:N") il valore
|
||||
# e' ridondante col subject; per le decisioni sostanziali con subject "a nome"
|
||||
# (cte_approved, ...) e' l'unico modo per filtrarle dopo un reopen.
|
||||
from tht.phase import current_phase
|
||||
|
||||
record = DecisionRecord(
|
||||
seq=len(list_decisions(session_dir)) + 1,
|
||||
ts=datetime.now(UTC),
|
||||
type=type,
|
||||
subject=subject,
|
||||
detail=detail,
|
||||
rationale=rationale,
|
||||
retracts=retracts,
|
||||
phase=current_phase(session_dir),
|
||||
)
|
||||
existing = list_decisions(session_dir)
|
||||
phase = current_phase(session_dir)
|
||||
records = [
|
||||
DecisionRecord(
|
||||
seq=len(existing) + index,
|
||||
ts=datetime.now(UTC),
|
||||
phase=phase,
|
||||
**decision.model_dump(),
|
||||
)
|
||||
for index, decision in enumerate(inputs, start=1)
|
||||
]
|
||||
path = session_dir / DECISIONS_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a") as f:
|
||||
f.write(record.model_dump_json() + "\n")
|
||||
return record
|
||||
previous = path.read_text() if path.exists() else ""
|
||||
separator = "" if not previous or previous.endswith("\n") else "\n"
|
||||
content = previous + separator + "".join(record.model_dump_json() + "\n" for record in records)
|
||||
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=f".{DECISIONS_FILE}.", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return records
|
||||
|
||||
Reference in New Issue
Block a user