Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions ADRs/0002-ndjson-file-storage-no-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,5 @@ The default trace directory is `.agent-traces/` relative to the working director
- **Zero dependencies** — NDJSON is readable with `cat`, `grep`, `jq`, or any JSON parser in any language.
- **Append-safe** — file appends are atomic at the OS level for writes smaller than the filesystem block size (~4KB). A single NDJSON line is always well under this limit.
- **No locking required** — Claude Code fires hooks sequentially, so concurrent writes from the same session are not expected.
- **No indexing** — session listing is O(n) directory iteration. Prefix matching for session IDs is O(n) over sessions. Acceptable for tens to hundreds of sessions.
- **Session listing is sorted by directory name** (reverse-alphabetical), not by `started_at` timestamp. UUID hex strings are not time-ordered, so the "latest" session is determined by sort order, not creation time. This is a known simplification.
- **No indexing** — session listing is O(n) directory iteration. Loaded metadata is sorted newest-first by `started_at`, with descending session ID as a deterministic tie-breaker. Prefix matching for session IDs remains O(n) over sessions. Acceptable for tens to hundreds of sessions.
- **No compaction or rotation** — traces grow indefinitely. Cleanup is manual.
2 changes: 1 addition & 1 deletion src/agent_trace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""agent-trace: strace for AI agents."""

__version__ = "0.38.0"
__version__ = "0.38.1"
3 changes: 1 addition & 2 deletions src/agent_trace/freshness.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,7 @@ def analyse_freshness(
) -> FreshnessReport:
"""Compute context freshness relative to the last session."""
# Find last session timestamp
all_metas = store.list_sessions()
last_meta = all_metas[-1] if all_metas else None
last_meta = store.get_latest_session()
last_ts = last_meta.started_at if last_meta else None
last_sid = last_meta.session_id if last_meta else ""

Expand Down
23 changes: 18 additions & 5 deletions src/agent_trace/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,36 @@ def load_events(self, session_id: str) -> list[TraceEvent]:
return events

def list_sessions(self) -> list[SessionMeta]:
"""Return valid sessions sorted newest first by started_at, then descending session ID."""
if not self.base_dir.exists():
return []
sessions = []
for d in sorted(self.base_dir.iterdir(), reverse=True):
for d in self.base_dir.iterdir():
meta_file = d / "meta.json"
if meta_file.exists():
try:
sessions.append(SessionMeta.from_json(meta_file.read_text()))
except (json.JSONDecodeError, TypeError):
continue
return sessions

def get_latest_session_id(self) -> str | None:
return sorted(
sessions,
key=lambda meta: (meta.started_at, meta.session_id),
reverse=True,
)

def get_latest_session(self) -> SessionMeta | None:
"""Return the newest session metadata, or None when the store is empty."""
sessions = self.list_sessions()
if not sessions:
return None
return sessions[0].session_id
return sessions[0]

def get_latest_session_id(self) -> str | None:
"""Return the newest session ID, or None when the store is empty."""
latest = self.get_latest_session()
if not latest:
return None
return latest.session_id

def session_exists(self, session_id: str) -> bool:
return (self._session_dir(session_id) / "meta.json").exists()
Expand Down
13 changes: 12 additions & 1 deletion tests/test_freshness.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ def test_analyse_freshness_with_session(self):
self.assertGreaterEqual(report.freshness_score, 0)
self.assertLessEqual(report.freshness_score, 100)

def test_analyse_freshness_uses_newest_session_by_started_at(self):
from agent_trace.freshness import analyse_freshness
store = _make_store(self._tmp)
old = SessionMeta(session_id="aa-old", started_at=1.0)
new = SessionMeta(session_id="zz-new", started_at=2.0)
store.create_session(old)
store.create_session(new)

report = analyse_freshness(store, repo=self._tmp)
self.assertEqual(report.last_session_id, "zz-new")
self.assertEqual(report.last_session_ts, 2.0)

def test_freshness_score_100_when_no_changes(self):
from agent_trace.freshness import analyse_freshness
store = _make_store(self._tmp)
Expand Down Expand Up @@ -104,4 +116,3 @@ def test_cli_has_freshness_command(self):
self.assertEqual(args.since, "2026-01-01")
self.assertEqual(args.scope, "src/**")


54 changes: 54 additions & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,60 @@ def test_list_sessions(self):
sessions = self.store.list_sessions()
self.assertEqual(len(sessions), 2)

def test_list_sessions_sorted_newest_first_by_started_at(self):
old = SessionMeta(session_id="zz-old", started_at=1.0)
new = SessionMeta(session_id="aa-new", started_at=2.0)
self.store.create_session(old)
self.store.create_session(new)

sessions = self.store.list_sessions()
self.assertEqual(
[(m.session_id, m.started_at) for m in sessions],
[("aa-new", 2.0), ("zz-old", 1.0)],
)

def test_list_sessions_uses_session_id_tiebreaker(self):
lower = SessionMeta(session_id="aa-same", started_at=1.0)
higher = SessionMeta(session_id="zz-same", started_at=1.0)
self.store.create_session(lower)
self.store.create_session(higher)

sessions = self.store.list_sessions()
self.assertEqual([m.session_id for m in sessions], ["zz-same", "aa-same"])

def test_list_sessions_skips_malformed_metadata(self):
valid = SessionMeta(session_id="valid", started_at=1.0)
self.store.create_session(valid)

malformed_dir = os.path.join(self.tmpdir, "malformed")
os.makedirs(malformed_dir)
with open(os.path.join(malformed_dir, "meta.json"), "w") as f:
f.write("{not json")
with open(os.path.join(self.tmpdir, "loose-file"), "w") as f:
f.write("ignored")

sessions = self.store.list_sessions()
self.assertEqual([m.session_id for m in sessions], ["valid"])

def test_get_latest_session_returns_newest_meta(self):
old = SessionMeta(session_id="zz-old", started_at=1.0)
new = SessionMeta(session_id="aa-new", started_at=2.0)
self.store.create_session(old)
self.store.create_session(new)

latest = self.store.get_latest_session()
self.assertIsNotNone(latest)
self.assertEqual(latest.session_id, "aa-new")
self.assertEqual(latest.started_at, 2.0)

def test_get_latest_session_id_uses_started_at_not_session_id(self):
old = SessionMeta(session_id="zz-old", started_at=1.0)
new = SessionMeta(session_id="aa-new", started_at=2.0)
self.store.create_session(old)
self.store.create_session(new)

self.assertEqual(self.store.get_latest_session_id(), "aa-new")

def test_find_session_by_prefix(self):
meta = SessionMeta()
self.store.create_session(meta)
Expand Down
Loading