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:
oliver 2026-05-14 09:33:50 +08:00
parent 2b32d11f43
commit d14e9d3596
103 changed files with 7574 additions and 1641 deletions

View file

@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""HTTP 冒烟:加载 _local/system.env → 用当前配置的 PG → 登录 → 建会话 → POST 一条消息 → 拉历史。
不依赖外网 LLM:强制 ``AIA_ASSISTANT_MODE=rule``(本地规则回复)。
用法(仓库根)::
python runtime/operations/scripts/smoke_admin_chat_postgres.py
退出码 0 表示全流程成功且 ``messages`` 里同时有 user 与 assistant。
"""
from __future__ import annotations
import hashlib
import os
import shutil
import sys
import tempfile
import uuid
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def main() -> int:
root = _repo_root()
os.chdir(root)
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from svc.config.bootstrap_env import load_system_env
load_system_env(force=True)
if (os.getenv("AIA_ASSISTANT_DB_BACKEND") or "").strip().lower() not in (
"postgresql",
"pg",
"postgres",
):
print("AIA_ASSISTANT_DB_BACKEND 不是 postgresql,本脚本用于测 PG。", file=sys.stderr)
return 2
# 不覆盖已有 shell 变量;仅保证规则模式(免外网)
os.environ.setdefault("AIA_ASSISTANT_MODE", "rule")
os.environ["AIA_ASSISTANT_MODE"] = "rule"
tmp = tempfile.mkdtemp(prefix="oclaw-smoke-ws-")
os.environ["OPS_WORKSPACE_ROOT"] = tmp
from svc.config import database as db_cfg
from svc.persistence.assistant_store import get_assistant_store, reset_assistant_store_singleton
from svc.persistence.db.engine import clear_assistant_engine_cache
clear_assistant_engine_cache()
reset_assistant_store_singleton()
if db_cfg.assistant_db_backend() != "postgresql":
print("assistant_db_backend() 不是 postgresql", file=sys.stderr)
return 2
from svc.persistence.sqlite_store import (
LLM_BUILTIN_RULE_PROFILE_ID,
SqliteStore,
active_llm_profile_setting_key,
)
store = get_assistant_store()
assert isinstance(store, SqliteStore) and store._use_pg
tag = uuid.uuid4().hex[:10]
pw = f"smoke-{tag}"
t = store.create_tenant(f"pg-smoke-{tag}")
tenant_id = str(t["id"])
# 不用 administrator:共享库里会走全局模型池与已导入的 OpenAI profile,易在无 Key 时得到空 reply。
u = store.create_user_account(
tenant_id=tenant_id,
username=f"smoke_{tag}",
display_name="Smoke",
role="owner",
password_hash=hashlib.sha256(pw.encode("utf-8")).hexdigest(),
is_active=True,
)
user_id = str(u.get("id") or "").strip()
if not user_id:
print("create_user_account returned no id", u, file=sys.stderr)
return 1
store.grant_llm_profile_to_user(
tenant_id=tenant_id,
profile_id=LLM_BUILTIN_RULE_PROFILE_ID,
user_id=user_id,
)
store.set_setting(
active_llm_profile_setting_key(user_id, f"smoke_{tag}"),
LLM_BUILTIN_RULE_PROFILE_ID,
)
from fastapi.testclient import TestClient
from interfaces.http.fastapi_app import create_app
client = TestClient(create_app())
try:
client.post("/admin/api/auth/bootstrap", json={})
lr = client.post(
"/admin/api/auth/login",
json={
"tenant_id": tenant_id,
"username": f"smoke_{tag}",
"password": pw,
"purpose": "console",
},
)
lj = lr.json()
if not lj.get("ok"):
print("login failed:", lr.status_code, lj, file=sys.stderr)
return 1
token = str(lj.get("token") or "")
h = {"authorization": f"Bearer {token}"}
cr = client.post("/admin/api/chat/sessions", json={"title": f"smoke-{tag}"}, headers=h)
if cr.status_code != 200:
print("create session:", cr.status_code, cr.text, file=sys.stderr)
return 1
cj = cr.json()
if not cj.get("ok"):
print("create session body:", cj, file=sys.stderr)
return 1
sid = str((cj.get("session") or {}).get("id") or "")
if not sid:
print("no session id", cj, file=sys.stderr)
return 1
mr = client.post(
f"/admin/api/chat/sessions/{sid}/messages",
json={"text": "ping-smoke-pg"},
headers=h,
)
if mr.status_code != 200:
print("send message:", mr.status_code, mr.text, file=sys.stderr)
return 1
mj = mr.json()
if not mj.get("ok"):
print("send message body:", mj, file=sys.stderr)
return 1
reply = str(mj.get("reply") or "")
if not reply.strip():
print("empty reply (unexpected for rule mode)", mj, file=sys.stderr)
return 1
gr = client.get(f"/admin/api/chat/sessions/{sid}/messages", headers=h)
if gr.status_code != 200:
print("list messages:", gr.status_code, gr.text, file=sys.stderr)
return 1
gj = gr.json()
msgs = gj.get("messages") or []
roles = [str(m.get("role") or "") for m in msgs if isinstance(m, dict)]
if "user" not in roles or "assistant" not in roles:
print("roles mismatch:", roles, "full ok=", gj.get("ok"), file=sys.stderr)
return 1
print("OK smoke_admin_chat_postgres")
print(" tenant_id=", tenant_id)
print(" session_id=", sid)
print(" reply_len=", len(reply))
print(" message_count=", len(msgs), "roles=", roles)
return 0
finally:
try:
shutil.rmtree(tmp, ignore_errors=True)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())