diff --git a/ADRs/0002-ndjson-file-storage-no-database.md b/ADRs/0002-ndjson-file-storage-no-database.md index e88b528..a3acbb1 100644 --- a/ADRs/0002-ndjson-file-storage-no-database.md +++ b/ADRs/0002-ndjson-file-storage-no-database.md @@ -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. diff --git a/src/agent_trace/__init__.py b/src/agent_trace/__init__.py index 7c0aa88..2a5b187 100644 --- a/src/agent_trace/__init__.py +++ b/src/agent_trace/__init__.py @@ -1,3 +1,3 @@ """agent-trace: strace for AI agents.""" -__version__ = "0.38.0" +__version__ = "0.38.1" diff --git a/src/agent_trace/freshness.py b/src/agent_trace/freshness.py index 8488a13..842d242 100644 --- a/src/agent_trace/freshness.py +++ b/src/agent_trace/freshness.py @@ -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 "" diff --git a/src/agent_trace/store.py b/src/agent_trace/store.py index 8d30654..3383554 100644 --- a/src/agent_trace/store.py +++ b/src/agent_trace/store.py @@ -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() diff --git a/tests/test_freshness.py b/tests/test_freshness.py index 6722164..451da65 100644 --- a/tests/test_freshness.py +++ b/tests/test_freshness.py @@ -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) @@ -104,4 +116,3 @@ def test_cli_has_freshness_command(self): self.assertEqual(args.since, "2026-01-01") self.assertEqual(args.scope, "src/**") - diff --git a/tests/test_store.py b/tests/test_store.py index 0201c40..2c2436b 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -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)