mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
- 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>
424 lines
15 KiB
Python
424 lines
15 KiB
Python
"""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"]
|