mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-19 12:53:15 +08:00
feat(persistence): PostgreSQL assistant store, chat persist fixes, gateway scripts
- Add SQLAlchemy Core repos, pg adapter/compat, assistant_store factory, Alembic bootstrap and migration/cutover scripts. - Harden chat_message writes (NUL scrub for PG), turn_uuid on attempt failure, WS turn_runner fallbacks and gateway executed_turn_uuid init. - start_gateway: log paths, PS7 stderr handling via cmd, background stdout/stderr redirect; runtime assistant_runtime_log_dir export. - Ops: clear_all_chat_sessions with PG-only --postgresql and env-gated wipe; clear_postgres_chat_sessions.ps1. - Tests: SA repos, pg compat, persist fallback, smoke env isolation; CI and docs touch-ups. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2b32d11f43
commit
d14e9d3596
103 changed files with 7574 additions and 1641 deletions
424
svc/persistence/sa_repos/chat_sessions.py
Normal file
424
svc/persistence/sa_repos/chat_sessions.py
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
"""chat_session (+ ui_session_owner joins) via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import and_, delete, distinct, func, insert, literal, or_, select, update
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import app_user, chat_message, chat_session, ui_session_owner
|
||||
from svc.persistence.sqlite_store import ChatSession, SessionsListMeta
|
||||
|
||||
|
||||
def _session_from_row(row: Mapping[str, Any]) -> ChatSession:
|
||||
return ChatSession(
|
||||
id=str(row["id"]),
|
||||
title=str(row["title"]),
|
||||
created_at=str(row["created_at"]),
|
||||
last_message_at=row["last_message_at"],
|
||||
)
|
||||
|
||||
|
||||
def _activity_order() -> tuple[Any, Any]:
|
||||
return (
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at).desc(),
|
||||
chat_session.c.created_at.desc(),
|
||||
)
|
||||
|
||||
|
||||
class ChatSessionsSaRepository:
|
||||
"""Phase-3 SA migration: chat session rows and list queries (messages stay raw SQL for now)."""
|
||||
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_chat_session(self, *, session_id: str, title: str, created_at: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(chat_session).values(
|
||||
id=str(session_id),
|
||||
title=str(title),
|
||||
created_at=str(created_at),
|
||||
last_message_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
def fetch_chat_session_by_id(self, *, session_id: str) -> ChatSession | None:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.where(chat_session.c.id == sid)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
return _session_from_row(row) if row else None
|
||||
|
||||
def fetch_chat_session_for_user(
|
||||
self, *, session_id: str, tenant_id: str, user_id: str
|
||||
) -> ChatSession | None:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
chat_session.c.id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
return _session_from_row(row) if row else None
|
||||
|
||||
def list_chat_sessions_global(self, *, limit: int | None, offset: int) -> list[ChatSession]:
|
||||
stmt = select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
).order_by(*_activity_order())
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [_session_from_row(r) for r in rows]
|
||||
|
||||
def list_chat_sessions_for_user(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
) -> list[ChatSession]:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
stmt = (
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
.order_by(*_activity_order())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [_session_from_row(r) for r in rows]
|
||||
|
||||
def list_chat_sessions_for_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
) -> list[ChatSession]:
|
||||
tid = str(tenant_id)
|
||||
stmt = (
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(ui_session_owner.c.tenant_id == tid)
|
||||
.distinct()
|
||||
.order_by(*_activity_order())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [_session_from_row(r) for r in rows]
|
||||
|
||||
def count_chat_sessions_global(self) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
n = conn.execute(select(func.count()).select_from(chat_session)).scalar_one()
|
||||
return int(n or 0)
|
||||
|
||||
def sessions_list_meta_global(self) -> SessionsListMeta:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
func.count().label("c"),
|
||||
func.max(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||
).label("latest_activity_at"),
|
||||
).select_from(chat_session)
|
||||
).mappings().first()
|
||||
return SessionsListMeta(
|
||||
session_count=int(row["c"] or 0) if row else 0,
|
||||
latest_activity_at=str(row["latest_activity_at"])
|
||||
if row and row.get("latest_activity_at") is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
def sessions_list_meta_for_user(self, *, tenant_id: str, user_id: str) -> SessionsListMeta:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
func.count().label("c"),
|
||||
func.max(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||
).label("latest_activity_at"),
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
).mappings().first()
|
||||
return SessionsListMeta(
|
||||
session_count=int(row["c"] or 0) if row else 0,
|
||||
latest_activity_at=str(row["latest_activity_at"])
|
||||
if row and row.get("latest_activity_at") is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
def sessions_list_meta_for_tenant(self, *, tenant_id: str) -> SessionsListMeta:
|
||||
tid = str(tenant_id)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
func.count(distinct(chat_session.c.id)).label("c"),
|
||||
func.max(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||
).label("latest_activity_at"),
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(ui_session_owner.c.tenant_id == tid)
|
||||
).mappings().first()
|
||||
return SessionsListMeta(
|
||||
session_count=int(row["c"] or 0) if row else 0,
|
||||
latest_activity_at=str(row["latest_activity_at"])
|
||||
if row and row.get("latest_activity_at") is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
def fetch_chat_session_in_tenant(self, *, session_id: str, tenant_id: str) -> ChatSession | None:
|
||||
sid, tid = str(session_id or "").strip(), str(tenant_id)
|
||||
if not sid:
|
||||
return None
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
chat_session.c.id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
return _session_from_row(row) if row else None
|
||||
|
||||
def list_admin_sessions(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
search_lower: str | None,
|
||||
active_only: bool,
|
||||
active_cutoff_iso: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""Tenant-scoped admin session browser (parity with raw ``list_admin_sessions`` SQL)."""
|
||||
tid = str(tenant_id or "").strip()
|
||||
if not tid:
|
||||
return 0, []
|
||||
uid = str(user_id or "").strip() or None
|
||||
q_text = str(search_lower or "").strip().lower() or None
|
||||
lim = max(1, min(int(limit), 500))
|
||||
off = max(0, int(offset))
|
||||
|
||||
msg_cnt = (
|
||||
select(func.count())
|
||||
.select_from(chat_message)
|
||||
.where(chat_message.c.session_id == chat_session.c.id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
joins = chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
).outerjoin(
|
||||
app_user,
|
||||
(app_user.c.tenant_id == ui_session_owner.c.tenant_id)
|
||||
& (app_user.c.id == ui_session_owner.c.user_id),
|
||||
)
|
||||
conds: list[Any] = [ui_session_owner.c.tenant_id == tid]
|
||||
if uid:
|
||||
conds.append(ui_session_owner.c.user_id == uid)
|
||||
if q_text:
|
||||
like = f"%{q_text}%"
|
||||
conds.append(
|
||||
or_(
|
||||
func.lower(func.coalesce(app_user.c.username, literal(""))).like(like),
|
||||
func.lower(func.coalesce(app_user.c.display_name, literal(""))).like(like),
|
||||
func.lower(func.coalesce(chat_session.c.title, literal(""))).like(like),
|
||||
)
|
||||
)
|
||||
if active_only:
|
||||
conds.append(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at) >= active_cutoff_iso
|
||||
)
|
||||
wh = and_(*conds)
|
||||
|
||||
data_stmt = (
|
||||
select(
|
||||
chat_session.c.id.label("session_id"),
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
ui_session_owner.c.user_id,
|
||||
func.coalesce(app_user.c.username, literal("")).label("username"),
|
||||
func.coalesce(app_user.c.display_name, literal("")).label("display_name"),
|
||||
msg_cnt.label("message_count"),
|
||||
)
|
||||
.select_from(joins)
|
||||
.where(wh)
|
||||
.order_by(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at).desc(),
|
||||
chat_session.c.created_at.desc(),
|
||||
)
|
||||
.limit(lim)
|
||||
.offset(off)
|
||||
)
|
||||
cnt_stmt = select(func.count()).select_from(joins).where(wh)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
total = int(conn.execute(cnt_stmt).scalar_one() or 0)
|
||||
rows = conn.execute(data_stmt).mappings().all()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
{
|
||||
"session_id": str(r["session_id"] or ""),
|
||||
"title": str(r["title"] or ""),
|
||||
"created_at": str(r["created_at"] or ""),
|
||||
"last_message_at": str(r["last_message_at"] or ""),
|
||||
"user_id": str(r["user_id"] or ""),
|
||||
"username": str(r["username"] or ""),
|
||||
"display_name": str(r["display_name"] or ""),
|
||||
"message_count": int(r["message_count"] or 0),
|
||||
}
|
||||
)
|
||||
return total, out
|
||||
|
||||
def rename_chat_session(self, *, session_id: str, title: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
update(chat_session)
|
||||
.where(chat_session.c.id == str(session_id))
|
||||
.values(title=str(title))
|
||||
)
|
||||
|
||||
def delete_chat_session_by_id(self, *, session_id: str) -> None:
|
||||
sid = str(session_id)
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||
|
||||
def try_delete_chat_session_for_tenant(self, *, session_id: str, tenant_id: str) -> bool:
|
||||
sid, tid = str(session_id or "").strip(), str(tenant_id)
|
||||
if not sid:
|
||||
return False
|
||||
with self._engine.begin() as conn:
|
||||
chk = conn.execute(
|
||||
select(1)
|
||||
.select_from(ui_session_owner)
|
||||
.where(
|
||||
ui_session_owner.c.session_id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not chk:
|
||||
return False
|
||||
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||
return True
|
||||
|
||||
def try_delete_chat_session_for_user(
|
||||
self, *, session_id: str, tenant_id: str, user_id: str
|
||||
) -> bool:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return False
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.begin() as conn:
|
||||
chk = conn.execute(
|
||||
select(1)
|
||||
.select_from(ui_session_owner)
|
||||
.where(
|
||||
ui_session_owner.c.session_id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not chk:
|
||||
return False
|
||||
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["ChatSessionsSaRepository"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue