feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""``SessionStore`` adapter for the Claude Agent SDK on top of :class:`Database`.
|
||||
|
||||
Entries are stored verbatim as JSON (``jsonb`` on Postgres), ordered by a
|
||||
per-key ``seq``. ``append`` is idempotent on ``entry["uuid"]`` because the
|
||||
SDK re-delivers a batch on retry; entries without a uuid are appended as
|
||||
they come. Runs on SQLite too - the conformance suite uses that in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from claude_agent_sdk import SessionStore
|
||||
from sqlalchemy import delete, func
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.storage.models import TranscriptEntry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from claude_agent_sdk.types import (
|
||||
SessionKey,
|
||||
SessionListSubkeysKey,
|
||||
SessionStoreEntry,
|
||||
)
|
||||
|
||||
from beaver_gateway.storage.db import Database
|
||||
|
||||
__all__ = ["PostgresSessionStore"]
|
||||
|
||||
|
||||
class PostgresSessionStore(SessionStore):
|
||||
def __init__(self, db: Database) -> None:
|
||||
self._db = db
|
||||
|
||||
async def append(self, key: SessionKey, entries: list[SessionStoreEntry]) -> None:
|
||||
if not entries:
|
||||
return
|
||||
project_key, session_id, subpath = _parts(key)
|
||||
wanted = [
|
||||
u for u in (e.get("uuid") for e in entries) if isinstance(u, str) and u
|
||||
]
|
||||
async with self._db.session() as session:
|
||||
known: set[str] = set()
|
||||
if wanted:
|
||||
result = await session.exec(
|
||||
select(TranscriptEntry.uuid).where(
|
||||
TranscriptEntry.project_key == project_key,
|
||||
TranscriptEntry.session_id == session_id,
|
||||
TranscriptEntry.subpath == subpath,
|
||||
col(TranscriptEntry.uuid).in_(wanted),
|
||||
)
|
||||
)
|
||||
known = {u for u in result.all() if u is not None}
|
||||
seq_result = await session.exec(
|
||||
select(func.max(TranscriptEntry.seq)).where(
|
||||
TranscriptEntry.project_key == project_key,
|
||||
TranscriptEntry.session_id == session_id,
|
||||
TranscriptEntry.subpath == subpath,
|
||||
)
|
||||
)
|
||||
seq = seq_result.one() or 0
|
||||
for entry in entries:
|
||||
uuid = entry.get("uuid")
|
||||
uuid_str = uuid if isinstance(uuid, str) and uuid else None
|
||||
if uuid_str is not None:
|
||||
if uuid_str in known:
|
||||
continue
|
||||
known.add(uuid_str)
|
||||
seq += 1
|
||||
session.add(
|
||||
TranscriptEntry(
|
||||
project_key=project_key,
|
||||
session_id=session_id,
|
||||
subpath=subpath,
|
||||
seq=seq,
|
||||
uuid=uuid_str,
|
||||
entry=cast("dict[str, Any]", dict(entry)),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None:
|
||||
project_key, session_id, subpath = _parts(key)
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(TranscriptEntry.entry)
|
||||
.where(
|
||||
TranscriptEntry.project_key == project_key,
|
||||
TranscriptEntry.session_id == session_id,
|
||||
TranscriptEntry.subpath == subpath,
|
||||
)
|
||||
.order_by(col(TranscriptEntry.seq))
|
||||
)
|
||||
rows = result.all()
|
||||
if not rows:
|
||||
return None
|
||||
return [cast("SessionStoreEntry", row) for row in rows]
|
||||
|
||||
async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(TranscriptEntry.subpath)
|
||||
.where(
|
||||
TranscriptEntry.project_key == key["project_key"],
|
||||
TranscriptEntry.session_id == key["session_id"],
|
||||
TranscriptEntry.subpath != "",
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return sorted(result.all())
|
||||
|
||||
async def delete(self, key: SessionKey) -> None:
|
||||
project_key, session_id, subpath = _parts(key)
|
||||
stmt = delete(TranscriptEntry).where(
|
||||
col(TranscriptEntry.project_key) == project_key,
|
||||
col(TranscriptEntry.session_id) == session_id,
|
||||
)
|
||||
if key.get("subpath"):
|
||||
stmt = stmt.where(col(TranscriptEntry.subpath) == subpath)
|
||||
async with self._db.session() as session:
|
||||
await session.execute(stmt) # ty: ignore[deprecated]
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _parts(key: SessionKey) -> tuple[str, str, str]:
|
||||
return key["project_key"], key["session_id"], key.get("subpath") or ""
|
||||
Reference in New Issue
Block a user