6Read, format, and summarise the persisted M2 event stream — M2.6.
10M2.1–M2.5 produce a per-session NDJSON event log (``events.NNNN.jsonl`` under
11the writer's base dir). This module is the first *reader* of that log: it
12locates a session's event files, parses each line back into a frozen
13``TurnEvent``, renders a compact human-readable trace (for ``/trace``), and
14assembles a minimal machine-readable ``packet-draft/v0`` bundle (for the draft
15``/packet``). The full merge-readiness packet is M7; this is observability only.
19- :func:`read_session_events` — resolve the session's event dir, read every
20 rotation slot in order, parse lines into ``TurnEvent``s, tolerate missing
21 dirs and torn/malformed lines.
22- :func:`format_trace` — compact per-event text rendering with an optional tail
23 limit and a trailing per-kind count summary.
24- :func:`build_packet_draft` — a pure ``dict`` snapshot (no disk writes) of the
25 session identity, per-kind counts, turn count, and last final answer.
31 L2 — read/packet lifecycle.
32 L3 — file/line counts and skipped malformed lines.
33 L4 — module-load coverage marker.
37- Reading never raises on missing dirs or torn lines; it returns ``[]`` or
38 skips the bad line and counts it.
39- ``build_packet_draft`` is pure and writes nothing.
40- The packet schema is ``"packet-draft/v0"`` — explicitly a draft distinct from
45- oc_diagnostics (structured debug logging via _dbg)
46- oc_chat.core.event_bootstrap (active_event_dir — locate the session's log)
47- oc_chat.core.events (TurnEvent — parse persisted lines)
48- oc_chat.core.session (lazy; ChatSession, for type hints only)
50from __future__
import annotations
53from datetime
import datetime, timezone
54from pathlib
import Path
55from typing
import TYPE_CHECKING, Any, Optional
57from oc_diagnostics
import _dbg
65_dbg(
"OC_CHAT.TRACE",
"trace_view",
"module", 4)
67PACKET_SCHEMA =
"packet-draft/v0"
68_SLOT_RE = re.compile(
r"^events\.(\d+)\.jsonl$")
69_DEFAULT_TRACE_LIMIT = 200
73 """Return the rotation sequence encoded in an ``events.NNNN.jsonl`` filename."""
74 match = _SLOT_RE.match(path.name)
75 return int(match.group(1))
if match
else 0
79 """Two persisted lines share an ``event_id`` but differ in bytes.
81 Surfaced rather than silently resolved: picking one of two conflicting
82 records for the same event id would fabricate an audit trail. Fail closed
83 (plan 152 ADR-8 / R10-05).
87def _read_one_root(base: Path, session_id: str) -> tuple[list[TurnEvent], int, int]:
88 """Parse one candidate root, keeping ONLY events whose payload sid matches.
90 Returns ``(events, torn, foreign)`` — torn/malformed lines and lines
91 belonging to a DIFFERENT session are each counted, never echoed (an event
92 payload carries prompts, assistant text and tool arguments).
95 (p
for p
in base.iterdir()
if p.is_file()
and _SLOT_RE.match(p.name)),
98 events: list[TurnEvent] = []
103 text = slot.read_text(encoding=
"utf-8")
107 for line
in text.splitlines():
111 event = TurnEvent.model_validate_json(line)
115 if str(getattr(event,
"session_id",
"")
or "") != session_id:
119 return events, torn, foreign
123 """Return every persisted ``TurnEvent`` for *session*, in write order.
125 Reads the IDENTITY-FILTERED MERGE of every candidate root that
126 :func:`~oc_chat.core.event_bootstrap.event_read_dirs` reports — the
127 ``session_id`` dir plus any legacy name-slug / persisted-alias / old direct
128 override roots — instead of a single directory (plan 152 ADR-8). Deriving
129 the write dir from the immutable ``session_id`` would otherwise hide a
130 pre-migration session's history behind the new directory.
134 - **Identity filter.** Only events whose payload ``session_id`` equals this
135 session's are included. Legacy slug directories are measurably NOT
136 identity partitions — real ones hold several sessions' streams — so
137 merging by directory name would attach another session's prompts and tool
138 arguments to this trace. Mismatches are skipped and COUNTED, never echoed.
139 - **Dedupe by ``event_id``.** The same event reachable through two roots
140 appears once. Two lines sharing an ``event_id`` but differing in bytes are
141 CORRUPTION and raise :class:`EventHistoryCorruption` — no silent pick.
142 - **Total order.** All legacy-root events precede all ``session_id``-dir
143 events (a migration-epoch fact: post-cutover writes go only to the id
144 dir), and within each root, slot order then line order — the writer's own
145 order. Equal timestamps never decide anything.
146 - **Read-only.** Nothing here creates, mutates or dirties session state.
148 A torn/malformed line is skipped and counted; missing dirs yield no events.
150 func =
"read_session_events"
153 session_id = str(getattr(session,
"session_id",
"")
or "")
154 roots = event_read_dirs(session)
155 events: list[TurnEvent] = []
156 by_id: dict[str, str] = {}
159 contributing_legacy = 0
161 for index, base
in enumerate(roots):
162 if not base.exists():
166 foreign_total += foreign
167 if found
and index < len(roots) - 1:
168 contributing_legacy += 1
170 event_id = str(getattr(event,
"event_id",
"")
or "")
171 payload = event.model_dump_json()
173 previous = by_id.get(event_id)
174 if previous
is not None:
175 if previous != payload:
177 f
"event_id {event_id} appears twice with different content "
178 f
"across event roots for session {session_id}"
181 by_id[event_id] = payload
184 if contributing_legacy > 1:
187 _dbg(
"OC_CHAT.TRACE", func,
188 f
"{contributing_legacy} legacy roots contributed events; "
189 f
"cross-root interleave order is indeterminate (legacy-before-id order applied)", 2)
190 _dbg(
"OC_CHAT.TRACE", func,
191 f
"read {len(events)} events from {len(roots)} root(s); "
192 f
"torn={torn_total} foreign_skipped={foreign_total}", 3)
196def _short(value: Optional[str], width: int = 8) -> str:
197 """Return the leading *width* chars of *value* (``"-"`` when empty)."""
198 return (value
or "-")[:width]
202 """Return a one-line key-field summary for *event* by kind."""
203 p: Any = event.payload
204 if event.kind ==
"user_input_received":
205 return f
"text={_short(getattr(p, 'text', ''), 60)!r}"
206 if event.kind ==
"assistant_message":
207 final = getattr(p,
"is_final",
False)
208 return f
"final={final} text={_short(getattr(p, 'text', ''), 60)!r}"
209 if event.kind ==
"tool_call_proposed":
210 return f
"tool={getattr(p, 'tool_name', '?')}"
211 if event.kind ==
"tool_call_started":
212 return f
"tool={getattr(p, 'tool_name', '?')} id={_short(getattr(p, 'tool_call_id', ''))}"
213 if event.kind ==
"tool_call_completed":
214 return f
"id={_short(getattr(p, 'tool_call_id', ''))} outcome={getattr(p, 'outcome', '?')}"
215 if event.kind
in (
"subagent_started",
"subagent_completed"):
216 sid =
_short(getattr(p,
"subagent_session_id",
""))
217 outcome = getattr(p,
"outcome",
None)
218 return f
"subagent={sid}" + (f
" outcome={outcome}" if outcome
else "")
219 if event.kind ==
"error":
220 return f
"error={_short(getattr(p, 'error_summary', ''), 60)!r}"
224def format_trace(events: list[TurnEvent], *, limit: Optional[int] = _DEFAULT_TRACE_LIMIT) -> str:
225 """Render *events* as compact lines + a per-kind count summary.
227 When *limit* is set, only the last *limit* events are rendered (the summary
228 still counts the full stream).
231 return "(no events recorded for this session yet)"
232 shown = events[-limit:]
if limit
else events
233 lines = [
"── Event trace ──"]
234 if len(shown) < len(events):
235 lines.append(f
"(showing last {len(shown)} of {len(events)} events)")
237 lines.append(f
" {ev.ts} {ev.kind:<22} turn={_short(ev.turn_id)} {_event_summary(ev)}")
238 counts: dict[str, int] = {}
240 counts[ev.kind] = counts.get(ev.kind, 0) + 1
241 lines.append(
"── Summary ──")
242 for kind
in sorted(counts):
243 lines.append(f
" {kind:<22} {counts[kind]}")
244 return "\n".join(lines)
248 """Assemble a minimal ``packet-draft/v0`` dict for *session* (pure; no I/O).
250 The full merge-readiness packet (diff, tests, rollback feasibility,
251 inverse-patches, signing) is M7; this draft proves the stream is consumable.
253 counts: dict[str, int] = {}
254 turn_ids: set[str] = set()
255 last_final: Optional[str] =
None
257 counts[ev.kind] = counts.get(ev.kind, 0) + 1
259 turn_ids.add(ev.turn_id)
260 if ev.kind ==
"assistant_message" and getattr(ev.payload,
"is_final",
False):
261 last_final = getattr(ev.payload,
"text",
None)
263 "schema": PACKET_SCHEMA,
264 "session_id": getattr(session,
"session_id",
""),
265 "session_name": session.name,
266 "model": session.model,
267 "active_plan_id": getattr(session,
"active_plan_id",
None),
268 "event_counts": counts,
269 "turn_count": len(turn_ids),
270 "last_final_answer": last_final,
271 "generated_at": datetime.now(timezone.utc).isoformat(timespec=
"milliseconds"),
str _event_summary(TurnEvent event)
tuple[list[TurnEvent], int, int] _read_one_root(Path base, str session_id)
dict build_packet_draft("ChatSession" session, list[TurnEvent] events)
str format_trace(list[TurnEvent] events, *, Optional[int] limit=_DEFAULT_TRACE_LIMIT)
str _short(Optional[str] value, int width=8)
list[TurnEvent] read_session_events("ChatSession" session)