testsAndMisc/python_pkg/session_autopsy/store.py
Krzysztof kuhy Rudnicki a83b5abc27
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
python_pkg: track session_autopsy, finishing its interrupted refactor
The transcript analyzer behind /compile-candidate lived only in the working
tree. Untracked meant unlinted — pre-commit only inspects files handed to it —
so nothing had ever checked it, and it had been abandoned mid-refactor with
every parse raising AttributeError: '_Accumulator' object has no attribute
'first_timestamp'.

The accumulator had been moved onto the grouped records dataclasses, but three
increment sites and parse_session still read flat attributes that no longer
existed. parse_session now hands over acc.meta and acc.counts directly; only
subagent_count comes from elsewhere, since no transcript line carries it.

Two modules broke the 500-line limit, so they were split along seams that were
already there. signatures.py takes the text-signature helpers — traces.py was
importing them from parse.py, making parse an accidental hub, and now neither
imports the other. stats.py takes the pure number and time helpers. parse.py
521 -> 458, detectors.py 559 -> 467.

The structural lint findings were refactored, not configured away.
_counter_candidates took eight arguments; the five naming which counter field to
mine and at what thresholds became a frozen _CounterSpec, so both call sites are
one line. _extract_skill carried in_span and a line budget as loop variables
across thirteen branches; a _SpanBuffer owns that state now, leaving the walk at
four. record() took twelve flat kwargs and now takes one override per record
block, with session_meta() so a partial meta override keeps the standard
timestamp — a bare SessionMeta(slug=...) would have nulled started_at and
quietly changed the per-week arithmetic. All 36 call sites rewritten by an AST
pass. tests/factories.py moved into conftest.py, where every other package here
keeps shared builders and where name-tests-test does not object.

965 tests pass at 100% statement and branch coverage, every hook is green, and
no suppression was added anywhere. `candidates` and `traces skill-finish` both
run against the real corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:56:43 +02:00

137 lines
3.9 KiB
Python

"""Flock-guarded persistence for sessions.jsonl.
The store is small (~1 record per session, hundreds total), so every write
is a full read-modify-replace under an exclusive lock; this keeps concurrent
SessionEnd hook firings and a parallel ``scan`` safe without a database.
"""
from __future__ import annotations
import fcntl
import json
from typing import TYPE_CHECKING
from typing_extensions import Self
from python_pkg.session_autopsy.records import SessionRecord, record_from_dict
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
SESSIONS_FILE = "sessions.jsonl"
LOCK_FILE = ".lock"
class StoreLock:
"""Context manager holding the store's exclusive flock."""
def __init__(self, home: Path) -> None:
"""Remember the store directory.
Args:
home: The autopsy home directory.
"""
self._lock_path = home / LOCK_FILE
self._handle = None
def __enter__(self) -> Self:
"""Create the home dir if needed and take the exclusive lock.
Returns:
Self, with the lock held.
"""
self._lock_path.parent.mkdir(parents=True, exist_ok=True)
self._handle = self._lock_path.open("a")
fcntl.flock(self._handle, fcntl.LOCK_EX)
return self
def __exit__(self, *exc_info: object) -> None:
"""Release the lock and close the handle.
Args:
exc_info: Ignored exception triple.
"""
if self._handle is not None:
fcntl.flock(self._handle, fcntl.LOCK_UN)
self._handle.close()
self._handle = None
def _iter_stored(home: Path) -> Iterator[SessionRecord]:
"""Yield every record currently in sessions.jsonl.
Args:
home: The autopsy home directory.
Yields:
Stored records; malformed lines are skipped silently.
"""
sessions_path = home / SESSIONS_FILE
if not sessions_path.is_file():
return
with sessions_path.open(encoding="utf-8") as handle:
for raw_line in handle:
stripped = raw_line.strip()
if not stripped:
continue
try:
data = json.loads(stripped)
except json.JSONDecodeError:
continue
if isinstance(data, dict):
yield record_from_dict(data)
def load_records(home: Path) -> list[SessionRecord]:
"""Load all stored records under the lock.
Args:
home: The autopsy home directory.
Returns:
All stored records, in file order.
"""
with StoreLock(home):
return list(_iter_stored(home))
def load_file_index(home: Path) -> dict[str, tuple[int, float]]:
"""Map transcript paths to their (size, mtime) at last analysis.
Used by ``scan`` to skip transcripts that have not changed.
Args:
home: The autopsy home directory.
Returns:
``{transcript_path: (file_size, file_mtime)}`` for every record.
"""
with StoreLock(home):
return {
record.transcript_path: (record.file_size, record.file_mtime)
for record in _iter_stored(home)
}
def upsert_records(home: Path, new_records: list[SessionRecord]) -> int:
"""Insert or replace records by session id, atomically.
Args:
home: The autopsy home directory.
new_records: Records to insert (replacing same-session entries).
Returns:
The total number of stored records after the write.
"""
with StoreLock(home):
merged = {record.session_id: record for record in _iter_stored(home)}
for record in new_records:
merged[record.session_id] = record
tmp_path = home / (SESSIONS_FILE + ".tmp")
with tmp_path.open("w", encoding="utf-8") as handle:
for record in merged.values():
handle.write(json.dumps(record.to_dict(), separators=(",", ":")) + "\n")
tmp_path.replace(home / SESSIONS_FILE)
return len(merged)