fix: make join approval atomic
This commit is contained in:
@@ -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
@@ -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