Source code for ralph.display.raw_overflow

"""Per-unit raw NDJSON overflow log writer."""

from __future__ import annotations

import contextlib
import re
import threading
import time
from typing import TYPE_CHECKING, BinaryIO, cast

if TYPE_CHECKING:
    from collections.abc import Callable
    from pathlib import Path

_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._-]")
DEFAULT_MAX_OVERFLOW_FILE_BYTES = 50 * 1024 * 1024
#: Userspace buffer for the persistent handle. Amortizes write syscalls
#: (and the fsevents they generate) across many appended lines.
_BUFFER_BYTES = 64 * 1024
#: Default seconds between forced flushes. MUST stay well below
#: ralph.timeout_defaults.LOG_GROWTH_SECONDS (30.0): operators tail this
#: file and the on-disk copy must never look wedged while the unit is live.
DEFAULT_FLUSH_INTERVAL_SECONDS = 5.0


def _sanitize_unit_id(unit_id: str) -> str:
    return _SAFE_CHARS.sub("_", unit_id)


[docs] class RawOverflowLog: """Append-mode raw log for a single work unit. Thread-safe. Holds one buffered file handle open for the unit's lifetime instead of opening/closing per line (the per-line pattern generated an fsevent storm on long runs). Silently no-ops on filesystem errors so the display path never crashes due to a read-only workspace. """ def __init__( self, workspace_root: Path, unit_id: str, *, max_bytes: int = DEFAULT_MAX_OVERFLOW_FILE_BYTES, flush_interval_seconds: float = DEFAULT_FLUSH_INTERVAL_SECONDS, now: Callable[[], float] = time.monotonic, ) -> None: safe_id = _sanitize_unit_id(unit_id) self.path = workspace_root / ".agent" / "raw" / f"{safe_id}.log" self._lock = threading.Lock() self._first_write = True self._disabled = False self._max_bytes = max(max_bytes, 0) self._bytes_written = 0 self._flush_interval = max(flush_interval_seconds, 0.0) self._now = now self._fh: BinaryIO | None = None self._last_flush = now()
[docs] def disable(self) -> None: """Permanently disable this log so future appends are no-ops.""" with self._lock: self._close_locked() self._disabled = True
[docs] def append(self, line: str) -> bool: """Write *line* to the overflow log. Returns True when the line was written. Returns False when the log is disabled, the byte cap has been reached, or an I/O error occurs. """ with self._lock: if self._disabled: return False try: text = line.rstrip("\n") + "\n" encoded = text.encode("utf-8") if self._bytes_written + len(encoded) > self._max_bytes: self._close_locked() self._disabled = True return False if self._fh is None: self.path.parent.mkdir(parents=True, exist_ok=True) mode = "wb" if self._first_write else "ab" handle_obj: object = self.path.open(mode, buffering=_BUFFER_BYTES) self._fh = cast("BinaryIO", handle_obj) self._first_write = False fh: BinaryIO | None = self._fh if fh is None: return False fh.write(encoded) self._bytes_written += len(encoded) if self._now() - self._last_flush >= self._flush_interval: fh.flush() self._last_flush = self._now() return True except (OSError, PermissionError): self._close_locked() self._disabled = True return False
[docs] def flush(self) -> None: """Force buffered bytes to disk. Never raises.""" with self._lock: if self._fh is not None: try: self._fh.flush() self._last_flush = self._now() except (OSError, PermissionError): self._close_locked() self._disabled = True
[docs] def close(self) -> None: """Flush and release the file handle. Idempotent; appends may reopen.""" with self._lock: self._close_locked()
def _close_locked(self) -> None: if self._fh is not None: with contextlib.suppress(OSError, PermissionError): self._fh.close() self._fh = None
[docs] def relative_reference(self, workspace_root: Path) -> str: """Return POSIX path relative to *workspace_root*, or absolute on error.""" try: return self.path.relative_to(workspace_root).as_posix() except ValueError: return self.path.as_posix()
@property def size_bytes(self) -> int: """Bytes appended so far (buffered bytes included). The idle watchdog's log-growth corroborator reads this to prove the unit is alive; it must advance on every append, not only on flush. Returns 0 before the first write. Never raises. The in-memory ``_bytes_written`` counter is the authoritative liveness signal — an on-disk ``stat()`` probe is intentionally avoided because a missing or unfetchable file (operator unlink, watcher quarantine, transient I/O error) must NOT silence the watchdog while the unit itself is still appending. """ return self._bytes_written @property def is_disabled(self) -> bool: """True when the log has been permanently disabled (byte cap reached or I/O error).""" return self._disabled
__all__ = [ "DEFAULT_FLUSH_INTERVAL_SECONDS", "DEFAULT_MAX_OVERFLOW_FILE_BYTES", "RawOverflowLog", ]