feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
"""SQLModel-backed persistence.
|
||||
|
||||
Two tables — :class:`Token`, :class:`AuditLog` — plus a thin
|
||||
:class:`Database` wrapper around an async SQLAlchemy engine. The
|
||||
``GatewayRuntime`` carries the handle so auth (token verify / touch)
|
||||
and admin (token CRUD + audit listing) can reach it.
|
||||
Tokens, audit, conversations, Agent SDK transcript entries and per-turn
|
||||
usage, plus a thin :class:`Database` wrapper around an async SQLAlchemy
|
||||
engine. ``GatewayRuntime`` carries the handle.
|
||||
"""
|
||||
|
||||
from beaver_gateway.storage.db import (
|
||||
Database,
|
||||
append_audit,
|
||||
append_usage,
|
||||
create_token,
|
||||
list_active_tokens,
|
||||
list_audit_records,
|
||||
@@ -16,13 +16,18 @@ from beaver_gateway.storage.db import (
|
||||
revoke_token,
|
||||
touch_token,
|
||||
)
|
||||
from beaver_gateway.storage.models import AuditLog, Token
|
||||
from beaver_gateway.storage.models import AuditLog, Token, TranscriptEntry, Usage
|
||||
from beaver_gateway.storage.session_store import PostgresSessionStore
|
||||
|
||||
__all__ = [
|
||||
"AuditLog",
|
||||
"Database",
|
||||
"PostgresSessionStore",
|
||||
"Token",
|
||||
"TranscriptEntry",
|
||||
"Usage",
|
||||
"append_audit",
|
||||
"append_usage",
|
||||
"create_token",
|
||||
"list_active_tokens",
|
||||
"list_audit_records",
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from beaver_gateway.storage.models import AuditLog, Token
|
||||
from beaver_gateway.storage.models import AuditLog, Token, Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
@@ -183,9 +183,18 @@ async def list_audit_records(
|
||||
return result.all()
|
||||
|
||||
|
||||
# ---- Usage --------------------------------------------------------------
|
||||
|
||||
|
||||
async def append_usage(session: AsyncSession, row: Usage) -> None:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Database",
|
||||
"append_audit",
|
||||
"append_usage",
|
||||
"create_token",
|
||||
"list_active_tokens",
|
||||
"list_audit_records",
|
||||
|
||||
@@ -23,8 +23,10 @@ needs an id uses ``Optional[int]`` so SQLAlchemy can autoincrement.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy import JSON, Column, Index, UniqueConstraint, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
@@ -92,6 +94,7 @@ class Conversation(SQLModel, table=True):
|
||||
frontend: str = Field(index=True)
|
||||
external_id: str = Field(index=True)
|
||||
agent_name: str = Field(index=True)
|
||||
session_id: str | None = Field(default=None, index=True)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
updated_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
@@ -125,4 +128,73 @@ class ConversationMessage(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
__all__ = ["AuditLog", "Conversation", "ConversationMessage", "Token"]
|
||||
class TranscriptEntry(SQLModel, table=True):
|
||||
"""One Agent SDK transcript line, mirrored from the session store protocol.
|
||||
|
||||
``(project_key, session_id, subpath)`` is the ``SessionKey``; ``subpath``
|
||||
is ``""`` for the main transcript so the unique constraints stay simple.
|
||||
``uuid`` is the SDK's idempotency key - the partial unique index rejects
|
||||
a replayed batch, entries without a uuid are appended as-is.
|
||||
"""
|
||||
|
||||
__tablename__ = "transcript_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"project_key", "session_id", "subpath", "seq", name="uq_transcript_seq"
|
||||
),
|
||||
Index(
|
||||
"uq_transcript_uuid",
|
||||
"project_key",
|
||||
"session_id",
|
||||
"subpath",
|
||||
"uuid",
|
||||
unique=True,
|
||||
postgresql_where=text("uuid IS NOT NULL"),
|
||||
sqlite_where=text("uuid IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
project_key: str = Field(index=True)
|
||||
session_id: str = Field(index=True)
|
||||
subpath: str = Field(default="")
|
||||
seq: int
|
||||
uuid: str | None = Field(default=None)
|
||||
entry: dict[str, Any] = Field(
|
||||
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=False)
|
||||
)
|
||||
origin: str | None = Field(default=None)
|
||||
source: str | None = Field(default=None)
|
||||
turn_id: str | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class Usage(SQLModel, table=True):
|
||||
"""Per-turn token accounting from ``ResultMessage.usage``."""
|
||||
|
||||
__tablename__ = "usage"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
ts: datetime = Field(default_factory=_utcnow, index=True)
|
||||
agent_name: str = Field(index=True)
|
||||
conversation_id: str | None = Field(default=None, index=True)
|
||||
session_id: str | None = Field(default=None, index=True)
|
||||
model: str
|
||||
effort: str | None = Field(default=None)
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cache_read_tokens: int = 0
|
||||
cache_creation_tokens: int = 0
|
||||
cost_usd: float | None = Field(default=None)
|
||||
duration_ms: int | None = Field(default=None)
|
||||
num_turns: int | None = Field(default=None)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuditLog",
|
||||
"Conversation",
|
||||
"ConversationMessage",
|
||||
"Token",
|
||||
"TranscriptEntry",
|
||||
"Usage",
|
||||
]
|
||||
|
||||
@@ -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