Skip to content

Commit 54173df

Browse files
committed
feat: persist session events to DB and add replay endpoint
Stream-json events are now batch-persisted to a session_events table (JSONB) during SSE streaming. Completed sessions can be replayed via GET /sessions/{id}/events. The streaming pipeline is refactored into layered generators: stream_events() -> stream_and_persist() for clean separation of concerns. - Add SessionEvent model with (session_id, event_id) unique constraint - Alembic migration for session_events table - Refactor stream_output() into stream_events() + stream_and_persist() - Batch INSERT with ON CONFLICT DO NOTHING for idempotent reconnects - Add GET /events replay endpoint with SessionEventsListResponse schema - Frontend: load stored events for terminal sessions (existingSessionId prop) - Fix FakeDockerClient log_lines to include trailing newlines - 14 new tests (stream_events, stream_and_persist, get_session_events)
1 parent 1d897fc commit 54173df

10 files changed

Lines changed: 503 additions & 35 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ infra/
4747
- **Container orchestration**: `container` module manages ephemeral Docker lifecycle, credential injection, result relay
4848
- **Skills as agents**: each skill is a self-contained folder with workflow definitions, mounted into containers
4949
- **SSE passthrough**: backend relays container output to frontend (no AI response generation in backend)
50+
- **Session persistence**: stream-json events are batch-persisted to `session_events` table (JSONB) during SSE streaming via `stream_and_persist()`. Completed sessions can be replayed from `GET /sessions/{id}/events`. The streaming pipeline is layered: `stream_events()` (raw tuples) -> `stream_and_persist()` (SSE + DB writes) or `stream_output()` (SSE only, for tests).
5051
- **Stream-json protocol**: containers emit NDJSON with 5 event types: `system` (init/retry), `assistant` (one event per content block — thinking/text/tool_use), `user` (tool_result), `result` (session end + metadata), `rate_limit_event`. The `result.result` field duplicates the last assistant text — only display assistant events, use result for status only. No `--include-partial-messages` flag, so no `stream_event` deltas.
5152
- **API prefix**: all routes under `/api/v1`
5253
- **Admin panel**: SQLAdmin at `/admin`, configured in `admin/views.py`
@@ -76,6 +77,8 @@ cd apps/api && uv run alembic revision --autogenerate -m "description" # New mi
7677

7778
- Tests use `AsyncClient` with `ASGITransport` (no real server)
7879
- `conftest.py` sets env vars (DATABASE_URL, SECRET_KEY, etc.) **before** any app imports — order matters
80+
- **`FakeDockerClient` log_lines**: must include trailing `\n` for the line-buffering logic in `stream_events()` to split correctly
81+
- **`get_db_context` in tests**: tests that call `stream_and_persist()` need a `db_with_factory` fixture that calls `set_session_factory()` / `clear_session_factory()` — see `test_service.py` for the pattern
7982

8083
## Environment
8184

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""add session_events table
2+
3+
Revision ID: f6a7b8c9d0e1
4+
Revises: e5f6a7b8c9d0
5+
Create Date: 2026-04-17 18:00:00.000000
6+
7+
Persists raw stream-json events (JSONB) for each container session
8+
so completed sessions can be replayed from the database.
9+
"""
10+
11+
from collections.abc import Sequence
12+
13+
import sqlalchemy as sa
14+
from alembic import op
15+
from sqlalchemy.dialects import postgresql
16+
17+
# revision identifiers, used by Alembic.
18+
revision: str = "f6a7b8c9d0e1"
19+
down_revision: str | None = "e5f6a7b8c9d0"
20+
branch_labels: str | Sequence[str] | None = None
21+
depends_on: str | Sequence[str] | None = None
22+
23+
24+
def upgrade() -> None:
25+
op.create_table(
26+
"session_events",
27+
sa.Column("session_id", sa.Uuid(), nullable=False),
28+
sa.Column("event_id", sa.Integer(), nullable=False),
29+
sa.Column("data", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
30+
sa.Column("id", sa.Uuid(), nullable=False),
31+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
32+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
33+
sa.ForeignKeyConstraint(["session_id"], ["container_sessions.id"], ondelete="CASCADE"),
34+
sa.PrimaryKeyConstraint("id"),
35+
sa.UniqueConstraint("session_id", "event_id", name="uq_session_events_session_event"),
36+
)
37+
op.create_index(op.f("ix_session_events_session_id"), "session_events", ["session_id"])
38+
39+
40+
def downgrade() -> None:
41+
op.drop_index(op.f("ix_session_events_session_id"), table_name="session_events")
42+
op.drop_table("session_events")

apps/api/src/helprs/modules/container/models.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import uuid
55
from datetime import datetime
66

7-
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
7+
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, UniqueConstraint
8+
from sqlalchemy.dialects.postgresql import JSONB
89
from sqlalchemy.orm import Mapped, mapped_column
910

1011
from helprs.core.database import Base
@@ -47,3 +48,22 @@ class ContainerSession(Base):
4748
)
4849
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
4950
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
51+
52+
53+
class SessionEvent(Base):
54+
"""A single stream-json event persisted from a container session.
55+
56+
Stores raw NDJSON events (assistant, system, user, result) as JSONB
57+
so completed sessions can be replayed from the database.
58+
"""
59+
60+
__tablename__ = "session_events"
61+
__table_args__ = (UniqueConstraint("session_id", "event_id", name="uq_session_events_session_event"),)
62+
63+
session_id: Mapped[uuid.UUID] = mapped_column(
64+
ForeignKey("container_sessions.id", ondelete="CASCADE"),
65+
nullable=False,
66+
index=True,
67+
)
68+
event_id: Mapped[int] = mapped_column(Integer, nullable=False)
69+
data: Mapped[dict] = mapped_column(JSONB, nullable=False)

apps/api/src/helprs/modules/container/router.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,20 @@
1414
CreateSessionRequest,
1515
SendMessageRequest,
1616
SendMessageResponse,
17+
SessionEventResponse,
18+
SessionEventsListResponse,
1719
StopSessionResponse,
1820
)
1921
from helprs.modules.container.service import (
2022
AioDockerClient,
2123
ContainerStatus,
2224
create_session,
25+
get_session_events,
2326
get_session_or_404,
2427
send_message,
2528
start_container,
2629
stop_container,
27-
stream_output,
30+
stream_and_persist,
2831
)
2932
from helprs.modules.installation.service import get_byok_config, mint_installation_token
3033

@@ -146,7 +149,7 @@ async def stream_container_output(
146149

147150
async def _event_stream():
148151
try:
149-
async for event in stream_output(docker, cs.container_id, offset=offset):
152+
async for event in stream_and_persist(docker, cs.container_id, session_id=session_id, offset=offset):
150153
yield event
151154
finally:
152155
await docker.close()
@@ -162,6 +165,26 @@ async def _event_stream():
162165
)
163166

164167

168+
@router.get("/sessions/{session_id}/events", response_model=SessionEventsListResponse)
169+
@limiter.limit("30/minute")
170+
async def get_session_events_endpoint(
171+
session_id: UUID,
172+
request: Request,
173+
db: DbSession,
174+
):
175+
"""Retrieve persisted stream-json events for a session.
176+
177+
Returns all events ordered by ``event_id``, suitable for replaying
178+
completed sessions in the frontend without an SSE connection.
179+
"""
180+
events = await get_session_events(db, session_id)
181+
return SessionEventsListResponse(
182+
session_id=session_id,
183+
events=[SessionEventResponse.model_validate(e) for e in events],
184+
total=len(events),
185+
)
186+
187+
165188
@router.post("/sessions/{session_id}/message", response_model=SendMessageResponse)
166189
@limiter.limit("30/minute")
167190
async def send_session_message(

apps/api/src/helprs/modules/container/schemas.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,21 @@ class StopSessionResponse(BaseModel):
8585
id: uuid.UUID
8686
status: str
8787
message: str
88+
89+
90+
class SessionEventResponse(BaseModel):
91+
"""A single persisted stream-json event."""
92+
93+
model_config = {"from_attributes": True}
94+
95+
event_id: int
96+
data: dict
97+
created_at: datetime
98+
99+
100+
class SessionEventsListResponse(BaseModel):
101+
"""All persisted events for a session."""
102+
103+
session_id: uuid.UUID
104+
events: list[SessionEventResponse]
105+
total: int

apps/api/src/helprs/modules/container/service.py

Lines changed: 119 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
import structlog
2323
from sqlalchemy import select
2424

25+
from helprs.core.database import get_db_context
2526
from helprs.core.exceptions import ExternalServiceError, NotFoundError
26-
from helprs.modules.container.models import ContainerSession, ContainerStatus
27+
from helprs.modules.container.models import ContainerSession, ContainerStatus, SessionEvent
2728

2829
if TYPE_CHECKING:
2930
from collections.abc import AsyncIterator
@@ -294,25 +295,17 @@ async def start_container(
294295
return cs
295296

296297

297-
async def stream_output(
298+
async def stream_events(
298299
docker: DockerClient,
299300
container_id: str,
300-
offset: int = 0,
301-
) -> AsyncIterator[str]:
302-
"""Async generator yielding container log lines as SSE events.
303-
304-
Docker may split long stdout lines (e.g. stream-json tool_result
305-
events with full file contents) across multiple log frames. We
306-
buffer chunks and only emit complete newline-delimited lines so
307-
the frontend always receives valid, parseable JSON per SSE event.
301+
) -> AsyncIterator[tuple[int, str]]:
302+
"""Yield ``(event_id, raw_line)`` tuples from container log stream.
308303
309-
Each event includes an incrementing ``id:`` field so that clients
310-
can resume from the last received event via the ``offset`` query
311-
parameter (number of events to skip).
304+
Handles Docker log frame buffering (long lines split across chunks),
305+
newline splitting, and keepalive signaling.
312306
313-
When the container is quiet (Claude is thinking), the Docker log
314-
stream produces no data. We send SSE comments (``:``) every
315-
``KEEPALIVE_INTERVAL`` seconds to prevent idle-timeout disconnects.
307+
Yields ``(0, "")`` as a sentinel for keepalive intervals (no data
308+
from container for 15 seconds — Claude is thinking).
316309
"""
317310
keepalive_interval = 15.0
318311
event_id = 0
@@ -329,19 +322,121 @@ async def stream_output(
329322
if not line:
330323
continue
331324
event_id += 1
332-
if event_id <= offset:
333-
continue
334-
yield f"id: {event_id}\ndata: {line}\n\n"
325+
yield (event_id, line)
335326
except TimeoutError:
336-
# No data from container — send SSE keepalive comment to
337-
# prevent the HTTP connection from being closed by proxies
338-
# or the browser. SSE comments are silently ignored by
339-
# EventSource clients.
340-
yield ": keepalive\n\n"
327+
yield (0, "")
341328
except StopAsyncIteration:
342329
break
343330

344331

332+
async def stream_output(
333+
docker: DockerClient,
334+
container_id: str,
335+
offset: int = 0,
336+
) -> AsyncIterator[str]:
337+
"""Yield SSE-formatted events without persistence.
338+
339+
Thin wrapper over :func:`stream_events` that formats tuples as SSE.
340+
Used by tests and code paths that don't need DB persistence.
341+
"""
342+
async for event_id, line in stream_events(docker, container_id):
343+
if event_id == 0:
344+
yield ": keepalive\n\n"
345+
continue
346+
if event_id <= offset:
347+
continue
348+
yield f"id: {event_id}\ndata: {line}\n\n"
349+
350+
351+
_PERSIST_BATCH_SIZE = 10
352+
353+
354+
async def stream_and_persist(
355+
docker: DockerClient,
356+
container_id: str,
357+
session_id: UUID,
358+
offset: int = 0,
359+
batch_size: int = _PERSIST_BATCH_SIZE,
360+
) -> AsyncIterator[str]:
361+
"""Stream container output as SSE while persisting events to the DB.
362+
363+
Wraps :func:`stream_events`, yields the same SSE format as
364+
:func:`stream_output`, but also batch-inserts events into the
365+
``session_events`` table via :func:`get_db_context`.
366+
367+
Events at or below *offset* are skipped for SSE output but still
368+
persisted (they may not have been flushed before a prior disconnect).
369+
Duplicate ``(session_id, event_id)`` pairs are silently ignored via
370+
``ON CONFLICT DO NOTHING``.
371+
"""
372+
from sqlalchemy.dialects.postgresql import insert as pg_insert
373+
374+
pending: list[tuple[int, dict]] = []
375+
376+
async def _flush() -> None:
377+
if not pending:
378+
return
379+
batch = pending[:]
380+
pending.clear()
381+
try:
382+
async with get_db_context() as db:
383+
stmt = pg_insert(SessionEvent.__table__).values(
384+
[{"session_id": session_id, "event_id": eid, "data": data} for eid, data in batch]
385+
)
386+
stmt = stmt.on_conflict_do_nothing(
387+
constraint="uq_session_events_session_event",
388+
)
389+
await db.execute(stmt)
390+
except Exception:
391+
await logger.aexception(
392+
"event_persist_failed",
393+
session_id=str(session_id),
394+
batch_size=len(batch),
395+
)
396+
397+
async for event_id, line in stream_events(docker, container_id):
398+
if event_id == 0:
399+
# Keepalive — flush pending if any
400+
await _flush()
401+
yield ": keepalive\n\n"
402+
continue
403+
404+
# Parse to JSONB-safe dict
405+
try:
406+
parsed = json.loads(line)
407+
except json.JSONDecodeError:
408+
parsed = {"_raw": line}
409+
410+
pending.append((event_id, parsed))
411+
412+
if len(pending) >= batch_size:
413+
await _flush()
414+
415+
if event_id <= offset:
416+
continue
417+
yield f"id: {event_id}\ndata: {line}\n\n"
418+
419+
# Final flush for remaining events
420+
await _flush()
421+
422+
423+
# ---------------------------------------------------------------------------
424+
# Session event retrieval
425+
# ---------------------------------------------------------------------------
426+
427+
428+
async def get_session_events(
429+
db: AsyncSession,
430+
session_id: UUID,
431+
) -> list[SessionEvent]:
432+
"""Return all persisted events for a session, ordered by event_id."""
433+
await get_session_or_404(db, session_id)
434+
result = await db.execute(
435+
select(SessionEvent).where(SessionEvent.session_id == session_id).order_by(SessionEvent.event_id)
436+
)
437+
return list(result.scalars().all())
438+
439+
345440
async def send_message(
346441
db: AsyncSession,
347442
session_id: UUID,

0 commit comments

Comments
 (0)