oclaw/svc/persistence/sa_repos/app_settings.py
oliver d14e9d3596 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>
2026-05-14 09:33:50 +08:00

141 lines
4.8 KiB
Python

"""app_setting access via SQLAlchemy Core (SQLite + PostgreSQL)."""
from __future__ import annotations
from collections.abc import Callable
from sqlalchemy import delete, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.engine import Connection, Engine
from svc.persistence.db.tables import app_setting
class AppSettingsSaRepository:
"""Phase-1 SA migration: ``app_setting`` reads/writes only."""
__slots__ = ("_engine",)
def __init__(self, engine: Engine) -> None:
self._engine = engine
def _dialect(self, conn: Connection) -> str:
return conn.engine.dialect.name
def _upsert(
self,
conn: Connection,
*,
key: str,
value: str,
is_secret: int,
updated_at: str,
) -> None:
dialect = self._dialect(conn)
if dialect == "sqlite":
ins = sqlite_insert(app_setting).values(
key=key,
value=value,
is_secret=is_secret,
updated_at=updated_at,
)
stmt = ins.on_conflict_do_update(
index_elements=[app_setting.c.key],
set_={
"value": ins.excluded.value,
"is_secret": ins.excluded.is_secret,
"updated_at": ins.excluded.updated_at,
},
)
elif dialect == "postgresql":
ins = pg_insert(app_setting).values(
key=key,
value=value,
is_secret=is_secret,
updated_at=updated_at,
)
stmt = ins.on_conflict_do_update(
index_elements=[app_setting.c.key],
set_={
"value": ins.excluded.value,
"is_secret": ins.excluded.is_secret,
"updated_at": ins.excluded.updated_at,
},
)
else:
raise RuntimeError(f"unsupported SQLAlchemy dialect for app_setting: {dialect!r}")
conn.execute(stmt)
def upsert_plain(self, *, key: str, value: str, updated_at: str) -> None:
with self._engine.begin() as conn:
self._upsert(conn, key=key, value=value, is_secret=0, updated_at=updated_at)
def upsert_secret(self, *, key: str, encoded_value: str, updated_at: str) -> None:
with self._engine.begin() as conn:
self._upsert(conn, key=key, value=encoded_value, is_secret=1, updated_at=updated_at)
def fetch_row(self, *, key: str) -> tuple[str, int] | None:
with self._engine.connect() as conn:
row = conn.execute(
select(app_setting.c.value, app_setting.c.is_secret).where(app_setting.c.key == key)
).one_or_none()
if row is None:
return None
return (str(row[0]), int(row[1]))
def delete_key(self, *, key: str) -> None:
with self._engine.begin() as conn:
conn.execute(delete(app_setting).where(app_setting.c.key == key))
def migrate_b64_secrets(
self,
*,
ts: str,
decode_secret: Callable[[str], str],
encode_secret: Callable[[str], str],
predicate_new_encoding: Callable[[str], bool],
) -> int:
"""Re-encode legacy ``b64:`` rows; idempotent. Returns rows updated."""
migrated = 0
with self._engine.begin() as conn:
rows = conn.execute(
select(app_setting.c.key, app_setting.c.value).where(
app_setting.c.is_secret == 1,
app_setting.c.value.like("b64:%"),
)
).all()
for k, v in rows:
key = str(k or "")
val = str(v or "")
if not key:
continue
try:
plain = decode_secret(val)
except Exception:
continue
enc = encode_secret(plain)
if enc != val and predicate_new_encoding(enc):
conn.execute(
update(app_setting)
.where(
app_setting.c.key == key,
app_setting.c.is_secret == 1,
)
.values(value=enc, updated_at=ts)
)
migrated += 1
return migrated
def count_legacy_b64_secrets(self) -> int:
with self._engine.connect() as conn:
n = conn.execute(
select(func.count()).select_from(app_setting).where(
app_setting.c.is_secret == 1,
app_setting.c.value.like("b64:%"),
)
).scalar_one()
return int(n or 0)
__all__ = ["AppSettingsSaRepository"]