#!/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, source_descriptor: int | None = None, ) -> str: """Retire only the inode previously inspected; preserve any replacement.""" pinned = os.fstat(source_descriptor) if source_descriptor is not None else source_stat if pinned.st_dev != source_stat.st_dev or pinned.st_ino != source_stat.st_ino: return "replacement" try: current = os.stat( path.name, dir_fd=board_descriptor, follow_symlinks=False, ) except FileNotFoundError: return "missing" if current.st_dev != pinned.st_dev or current.st_ino != pinned.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"{pinned.st_dev:x}\0{pinned.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 == pinned.st_dev and staged.st_ino == pinned.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 cleanup_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") cleanup_descriptor = os.dup(stream.fileno()) 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, source_descriptor=cleanup_descriptor, ) if cleanup_descriptor is not None: os.close(cleanup_descriptor) 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