286 lines
9.8 KiB
Python
286 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Immutable terminal evidence creation and identity-safe retirement."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import contextlib
|
|
import errno
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import stat
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from cli_lane_config import MAX_TERMINAL_RECORD_BYTES, TerminalIdentity, utc_now
|
|
from cli_lane_files import _terminal_evidence_identity, state_path
|
|
from cli_lane_records import _load_small_json, _terminal_record_valid
|
|
|
|
|
|
def _rename_noreplace(
|
|
source: str,
|
|
destination: str,
|
|
*,
|
|
source_dir: int,
|
|
destination_dir: int,
|
|
) -> None:
|
|
"""Atomically rename a directory entry without replacing a collision."""
|
|
renameat2 = getattr(ctypes.CDLL(None, use_errno=True), "renameat2", None)
|
|
if renameat2 is None:
|
|
raise OSError(errno.ENOSYS, "renameat2 is unavailable")
|
|
renameat2.argtypes = (
|
|
ctypes.c_int,
|
|
ctypes.c_char_p,
|
|
ctypes.c_int,
|
|
ctypes.c_char_p,
|
|
ctypes.c_uint,
|
|
)
|
|
renameat2.restype = ctypes.c_int
|
|
result = renameat2(
|
|
source_dir,
|
|
ctypes.c_char_p(os.fsencode(source)),
|
|
destination_dir,
|
|
ctypes.c_char_p(os.fsencode(destination)),
|
|
1, # RENAME_NOREPLACE
|
|
)
|
|
if result != 0:
|
|
error = ctypes.get_errno()
|
|
raise OSError(error, os.strerror(error), source, destination)
|
|
|
|
def _retire_terminal_entry(
|
|
path: Path,
|
|
source_stat: os.stat_result,
|
|
*,
|
|
board_descriptor: int,
|
|
quarantine_descriptor: int,
|
|
authority_name: str | None = None,
|
|
) -> str:
|
|
"""Retire only the inode previously inspected; preserve any replacement."""
|
|
try:
|
|
current = os.stat(
|
|
path.name,
|
|
dir_fd=board_descriptor,
|
|
follow_symlinks=False,
|
|
)
|
|
except FileNotFoundError:
|
|
return "missing"
|
|
if current.st_dev != source_stat.st_dev or current.st_ino != source_stat.st_ino:
|
|
return "replacement"
|
|
# A replacement can itself be staged repeatedly while recovery races a
|
|
# writer. Keep every generation bound to the original canonical pending
|
|
# name instead of hashing an intermediate .retire name and hiding it from
|
|
# the next recovery pass.
|
|
path_digest = hashlib.sha256(
|
|
(authority_name or path.name).encode("utf-8")
|
|
).hexdigest()[:16]
|
|
source_digest = hashlib.sha256(
|
|
f"{source_stat.st_dev:x}\0{source_stat.st_ino:x}".encode()
|
|
).hexdigest()[:16]
|
|
for sequence in range(32):
|
|
staging = f".retire.{path_digest}.{source_digest}.{sequence}"
|
|
try:
|
|
_rename_noreplace(
|
|
path.name,
|
|
staging,
|
|
source_dir=board_descriptor,
|
|
destination_dir=quarantine_descriptor,
|
|
)
|
|
except FileExistsError:
|
|
continue
|
|
break
|
|
else:
|
|
return "collision"
|
|
staged = os.stat(
|
|
staging,
|
|
dir_fd=quarantine_descriptor,
|
|
follow_symlinks=False,
|
|
)
|
|
if staged.st_dev == source_stat.st_dev and staged.st_ino == source_stat.st_ino:
|
|
os.unlink(staging, dir_fd=quarantine_descriptor)
|
|
os.fsync(quarantine_descriptor)
|
|
os.fsync(board_descriptor)
|
|
return "retired"
|
|
# The path changed after the pre-check but before the rename. Put the
|
|
# replacement back without overwriting an even newer entry.
|
|
try:
|
|
_rename_noreplace(
|
|
staging,
|
|
path.name,
|
|
source_dir=quarantine_descriptor,
|
|
destination_dir=board_descriptor,
|
|
)
|
|
except FileExistsError:
|
|
return "replacement-staged"
|
|
os.fsync(quarantine_descriptor)
|
|
os.fsync(board_descriptor)
|
|
return "replacement"
|
|
|
|
def _write_json_noreplace(path: Path, value: dict[str, Any]) -> bool:
|
|
"""Durably create one immutable JSON artifact without replacing a peer."""
|
|
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
if len(payload) > MAX_TERMINAL_RECORD_BYTES + 4096:
|
|
raise ValueError("terminal evidence exceeds the bounded artifact limit")
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
os.chmod(path.parent, 0o700, follow_symlinks=False)
|
|
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
create_flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
directory = os.open(path.parent, directory_flags)
|
|
temporary = f".{path.name}.{uuid.uuid4().hex}.tmp"
|
|
temporary_stat = None
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(temporary, create_flags, 0o600, dir_fd=directory)
|
|
temporary_stat = os.fstat(descriptor)
|
|
with os.fdopen(descriptor, "wb") as stream:
|
|
descriptor = None
|
|
stream.write(payload)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
written = os.fstat(stream.fileno())
|
|
if stat.S_IMODE(written.st_mode) != 0o600 or written.st_nlink != 1:
|
|
raise OSError("terminal evidence is not private and singly linked")
|
|
try:
|
|
_rename_noreplace(
|
|
temporary,
|
|
path.name,
|
|
source_dir=directory,
|
|
destination_dir=directory,
|
|
)
|
|
except FileExistsError:
|
|
return False
|
|
os.fsync(directory)
|
|
return True
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
if temporary_stat is not None:
|
|
with contextlib.suppress(OSError):
|
|
_retire_terminal_entry(
|
|
Path(temporary),
|
|
temporary_stat,
|
|
board_descriptor=directory,
|
|
quarantine_descriptor=directory,
|
|
)
|
|
os.close(directory)
|
|
|
|
def _terminal_document_digest(document: dict[str, Any]) -> str:
|
|
"""Return the stable identity for one exact accepted result document."""
|
|
immutable = {
|
|
key: document.get(key)
|
|
for key in (
|
|
"board",
|
|
"task_id",
|
|
"expected_run_id",
|
|
"result",
|
|
"summary",
|
|
"metadata",
|
|
)
|
|
}
|
|
canonical = json.dumps(immutable, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
|
|
|
def _terminal_evidence_path(
|
|
identity: TerminalIdentity,
|
|
state: str,
|
|
document: dict[str, Any],
|
|
) -> Path:
|
|
"""Return a deterministic immutable prepared or conflict evidence path."""
|
|
if state not in {"prepared", "conflict"}:
|
|
raise ValueError(f"invalid terminal evidence state: {state}")
|
|
base = state_path(identity.board, identity.task_id)
|
|
digest = _terminal_document_digest(document)
|
|
return base.with_name(
|
|
f"{base.stem}.run-{identity.run_id}.terminal.{state}-{digest}.json"
|
|
)
|
|
|
|
def _same_terminal_document(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
"""Compare the immutable result-bearing fields of terminal evidence."""
|
|
keys = ("board", "task_id", "expected_run_id", "result", "summary", "metadata")
|
|
return all(left.get(key) == right.get(key) for key in keys)
|
|
|
|
def _terminal_evidence_valid(
|
|
document: dict[str, Any],
|
|
identity: TerminalIdentity,
|
|
state: str,
|
|
) -> bool:
|
|
"""Validate semantic terminal content plus its immutable evidence state."""
|
|
if document.get("kanban_state") != state:
|
|
return False
|
|
semantic = dict(document)
|
|
semantic_identity = identity
|
|
if state == "conflict":
|
|
semantic["kanban_state"] = "prepared"
|
|
semantic_identity = TerminalIdentity(
|
|
identity.board,
|
|
identity.task_id,
|
|
identity.run_id,
|
|
"prepared",
|
|
)
|
|
return _terminal_record_valid(semantic, semantic_identity)
|
|
|
|
def _persist_prepared_evidence(
|
|
identity: TerminalIdentity,
|
|
document: dict[str, Any],
|
|
) -> Path:
|
|
"""Fsync an immutable accepted result before entering the DB boundary."""
|
|
prepared = dict(document)
|
|
prepared["kanban_state"] = "prepared"
|
|
prepared["prepared_at"] = utc_now()
|
|
path = _terminal_evidence_path(identity, "prepared", document)
|
|
if _write_json_noreplace(path, prepared):
|
|
return path
|
|
if _terminal_evidence_identity(path) != TerminalIdentity(
|
|
identity.board, identity.task_id, identity.run_id, "prepared"
|
|
):
|
|
raise OSError("prepared terminal evidence identity is invalid")
|
|
existing = _load_small_json(path)
|
|
prepared_identity = TerminalIdentity(
|
|
identity.board,
|
|
identity.task_id,
|
|
identity.run_id,
|
|
"prepared",
|
|
)
|
|
if not _terminal_evidence_valid(
|
|
existing,
|
|
prepared_identity,
|
|
"prepared",
|
|
) or not _same_terminal_document(existing, prepared):
|
|
raise OSError("prepared terminal evidence path contains a conflicting result")
|
|
return path
|
|
|
|
def _persist_conflict_evidence(
|
|
identity: TerminalIdentity,
|
|
document: dict[str, Any],
|
|
reason: str,
|
|
) -> Path:
|
|
"""Preserve a losing valid result in full under deterministic retention."""
|
|
conflict = dict(document)
|
|
conflict["kanban_state"] = "conflict"
|
|
conflict["conflict_reason"] = reason
|
|
conflict["conflicted_at"] = utc_now()
|
|
path = _terminal_evidence_path(identity, "conflict", document)
|
|
if _write_json_noreplace(path, conflict):
|
|
return path
|
|
if _terminal_evidence_identity(path) != TerminalIdentity(
|
|
identity.board, identity.task_id, identity.run_id, "conflict"
|
|
):
|
|
raise OSError("terminal conflict evidence identity is invalid")
|
|
existing = _load_small_json(path)
|
|
conflict_identity = TerminalIdentity(
|
|
identity.board,
|
|
identity.task_id,
|
|
identity.run_id,
|
|
"conflict",
|
|
)
|
|
if not _terminal_evidence_valid(
|
|
existing,
|
|
conflict_identity,
|
|
"conflict",
|
|
) or not _same_terminal_document(existing, conflict):
|
|
raise OSError("terminal conflict evidence path contains a different result")
|
|
return path
|