mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-12 16:30:58 +01:00
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>
137 lines
3.9 KiB
Python
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)
|