fix: make join approval atomic

This commit is contained in:
User
2026-07-14 15:24:06 +02:00
parent bc02213d3e
commit 333874a755
9 changed files with 314 additions and 34 deletions
+37
View File
@@ -1,7 +1,10 @@
import json
import sys
from pathlib import Path
from typing import get_args
import typer
from pydantic import ValidationError
from tht.cli.config_cmd import CONFIG_OPT
from tht.cli.schema_cmd import _load_config_or_exit
@@ -10,6 +13,40 @@ from tht.cli.session_cmd import load_session_or_exit, session_dir
decision_app = typer.Typer(help="Decisioni del reviewer (per sessione, append-only)")
@decision_app.command("add-join-set")
def add_join_set_cmd(
session: str = typer.Option(..., "--session", help="Id della sessione."),
doc: str = typer.Option(..., "--doc", help="Array JSON di join; usa '-' per stdin."),
config: Path = CONFIG_OPT,
) -> None:
"""Registra un insieme completo di join con un'unica sostituzione atomica del ledger."""
from tht.decisions import DecisionInput, append_decisions
cfg = _load_config_or_exit(config)
load_session_or_exit(cfg, session)
try:
raw = sys.stdin.read() if doc == "-" else Path(doc).read_text()
payload = json.loads(raw)
if not isinstance(payload, list) or not payload:
raise ValueError("il documento deve essere un array JSON non vuoto")
decisions = [DecisionInput.model_validate(item) for item in payload]
if any(decision.type != "join_modified" for decision in decisions):
raise ValueError("tutte le decisioni devono avere type=join_modified")
except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
typer.secho(f"ERRORE: set di join non valido: {error}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1) from error
from tht.cli.phase_cmd import require_phase_or_exit
from tht.workflow import load_workflow
require_phase_or_exit(cfg, session, load_workflow().decision_min_phase("join_modified"))
records = append_decisions(session_dir(cfg, session), decisions)
typer.secho(
f"OK: registrato set atomico di {len(records)} join.",
fg=typer.colors.GREEN,
)
@decision_app.command("add")
def add_cmd(
session: str = typer.Option(..., "--session", help="Id della sessione."),
+57 -13
View File
@@ -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