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,251 @@
#!/usr/bin/env python3
"""对「正在运行」的网关发一条与控制台 Chat 相同的 HTTP 消息,并打印模型 / 会话模式 / 响应 / 库尾行。
本机执行(需网关已启动,且与浏览器访问的是同一 ``--base-url``)::
# 方式一:用密码登录(tenant 可省略,默认取库里第一个团队)
set AIA_LIVE_CHAT_PASSWORD=你的控制台密码
python runtime/operations/scripts/live_chat_probe.py --session-id <浏览器地址栏里的会话id>
# 方式二:从浏览器开发者工具复制 Bearer token
python runtime/operations/scripts/live_chat_probe.py --session-id <id> --token <jwt>
可选:加 ``--dump-db`` 时,会再读 ``_local/system.env`` 里的 ``AIA_ASSISTANT_*``,直连同一 PG/SQLite 打印该会话最近几条 ``chat_message``(用于对照「HTTP 成功但库里没有 assistant」)。
注意:Cursor 里的 AI **不能**替你连本机浏览器或 WebSocket;此脚本等价于你自己在 Network 里手动 POST,只是自动带上当前「选用模型」与会话模式字段。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _load_local_env_for_db_only() -> None:
root = _repo_root()
p = root / "_local" / "system.env"
if not p.is_file():
return
for raw in p.read_text(encoding="utf-8", errors="replace").splitlines():
s = raw.strip()
if not s or s.startswith("#") or "=" not in s:
continue
k, _, rest = s.partition("=")
key = k.strip()
if not key or key in os.environ:
continue
if not key.startswith("AIA_ASSISTANT_") and key not in (
"OPS_ASSISTANT_DB_BACKEND",
"OPS_ASSISTANT_DATABASE_URL",
"OPS_ASSISTANT_DB_PATH",
):
continue
val = rest.strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
val = val[1:-1]
os.environ[key] = val
def _dump_db_messages(session_id: str, limit: int) -> None:
_load_local_env_for_db_only()
root = _repo_root()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from sqlalchemy import func, select
from svc.persistence.db.engine import clear_assistant_engine_cache, get_assistant_engine
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.db.tables import chat_message
clear_assistant_engine_cache()
reset_assistant_store_singleton()
eng = get_assistant_engine()
print("\n--- DB (same env as _local/system.env assistant store) ---")
print("engine:", str(eng.url).split("@")[-1])
tc_len = func.length(func.coalesce(chat_message.c.tool_calls, ""))
stmt = (
select(
chat_message.c.id,
chat_message.c.role,
chat_message.c.event_type,
func.length(func.coalesce(chat_message.c.content, "")).label("content_len"),
(tc_len > 2).label("has_tc"),
)
.where(chat_message.c.session_id == session_id)
.order_by(chat_message.c.id.desc())
.limit(limit)
)
with eng.connect() as c:
n = c.execute(
select(func.count()).select_from(chat_message).where(chat_message.c.session_id == session_id)
).scalar()
print("chat_message count:", int(n or 0))
rows = list(c.execute(stmt).mappings())
for r in reversed(rows):
print(dict(r))
def _auth_header(token: str) -> dict[str, str]:
t = str(token or "").strip()
if t.lower().startswith("bearer "):
t = t[7:].strip()
return {"authorization": f"Bearer {t}"}
def main() -> int:
ap = argparse.ArgumentParser(description="Live HTTP chat probe against running gateway")
ap.add_argument("--base-url", default=os.getenv("AIA_LIVE_CHAT_BASE_URL", "http://127.0.0.1:8787").rstrip("/"))
ap.add_argument("--session-id", required=True, help="当前 Chat 会话 id(与浏览器一致)")
ap.add_argument("--text", default="请用一句话回复:当前探针在测试落库。", help="发送的正文")
ap.add_argument("--token", default=os.getenv("AIA_LIVE_CHAT_TOKEN", "").strip(), help="Bearer token(可设环境变量)")
ap.add_argument("--tenant-id", default=os.getenv("AIA_LIVE_CHAT_TENANT_ID", "").strip())
ap.add_argument("--username", default=os.getenv("AIA_LIVE_CHAT_USERNAME", "administrator").strip())
ap.add_argument("--password", default=os.getenv("AIA_LIVE_CHAT_PASSWORD", "").strip())
ap.add_argument("--dump-db", action="store_true", help="发送后再读本地 assistant 库该会话消息尾")
ap.add_argument("--db-tail", type=int, default=8, help="--dump-db 时打印最近几条")
args = ap.parse_args()
try:
import httpx
except ImportError:
print("需要 httpx: pip install httpx", file=sys.stderr)
return 2
sid = str(args.session_id).strip()
if not sid:
print("session-id 为空", file=sys.stderr)
return 2
base = str(args.base_url).strip().rstrip("/")
timeout = httpx.Timeout(300.0, connect=15.0)
with httpx.Client(base_url=base, timeout=timeout) as client:
token = str(args.token or "").strip()
if not token:
pw = str(args.password or "").strip()
if not pw:
print(
"未提供 token:请设置 --password 或环境变量 AIA_LIVE_CHAT_PASSWORD,"
"或 --token / AIA_LIVE_CHAT_TOKEN",
file=sys.stderr,
)
return 2
r0 = client.post("/admin/api/auth/bootstrap", json={})
if r0.status_code != 200:
print("bootstrap", r0.status_code, r0.text, file=sys.stderr)
return 1
body: dict[str, Any] = {
"username": str(args.username),
"password": pw,
"purpose": "console",
}
tid = str(args.tenant_id or "").strip()
if tid:
body["tenant_id"] = tid
lr = client.post("/admin/api/auth/login", json=body)
if lr.status_code != 200:
print("login http", lr.status_code, lr.text, file=sys.stderr)
return 1
lj = lr.json()
if not lj.get("ok"):
print("login", lj, file=sys.stderr)
return 1
token = str(lj.get("token") or "").strip()
if not token:
print("login 无 token", lj, file=sys.stderr)
return 1
sess = lj.get("session") or {}
print(
"login ok tenant_id=",
str(sess.get("tenant_id") or ""),
"user_id=",
str(sess.get("user_id") or ""),
"username=",
str(sess.get("username") or ""),
)
h = _auth_header(token)
mr = client.get("/admin/api/models", headers=h)
if mr.status_code != 200:
print("GET /models", mr.status_code, mr.text, file=sys.stderr)
return 1
mj = mr.json()
if not mj.get("ok"):
print("models", mj, file=sys.stderr)
return 1
active = str(mj.get("active_llm_profile_id") or "")
profiles = mj.get("profiles") or []
name = ""
mode = ""
model = ""
for p in profiles:
if isinstance(p, dict) and str(p.get("id") or "") == active:
name = str(p.get("name") or "")
mode = str(p.get("mode") or "")
model = str(p.get("model") or "")
break
print("\n--- 当前选用模型(与控制台一致) ---")
print("active_llm_profile_id:", active)
print("profile:", name, "| mode:", mode, "| model:", model)
dbp = mj.get("db_path")
if dbp is not None:
print("gateway reports db_path:", dbp)
gr = client.get(f"/admin/api/chat/sessions/{sid}/mode", headers=h)
if gr.status_code != 200:
print("GET session/mode", gr.status_code, gr.text, file=sys.stderr)
return 1
gj = gr.json()
if not gj.get("ok"):
print("session/mode", gj, file=sys.stderr)
return 1
print("\n--- 当前会话模式(将原样带入 POST) ---")
print(json.dumps({k: gj.get(k) for k in ("interaction_mode", "specialist", "memory_mode", "execution_mode", "confirm_strategy", "plan_agent_version") if k in gj}, ensure_ascii=False))
payload = {
"text": str(args.text),
"interaction_mode": gj.get("interaction_mode"),
"specialist": gj.get("specialist"),
"memory_mode": gj.get("memory_mode"),
"execution_mode": gj.get("execution_mode"),
}
pr = client.post(f"/admin/api/chat/sessions/{sid}/messages", headers=h, json=payload)
if pr.status_code != 200:
print("POST messages", pr.status_code, pr.text, file=sys.stderr)
return 1
pj = pr.json()
print("\n--- POST /messages 响应 ---")
print(json.dumps(pj, ensure_ascii=False, indent=2)[:8000])
if not pj.get("ok"):
return 1
reply = str(pj.get("reply") or "")
print("\nreply 非空:", bool(reply.strip()), "len=", len(reply))
lr2 = client.get(f"/admin/api/chat/sessions/{sid}/messages?limit=50", headers=h)
if lr2.status_code == 200:
lj2 = lr2.json()
msgs = lj2.get("messages") or []
roles = [str(m.get("role") or "") for m in msgs if isinstance(m, dict)]
print("\n--- GET messages(接口返回,最多 50 条) ---")
print("count=", len(msgs), "roles=", roles[-12:])
if args.dump_db:
try:
_dump_db_messages(sid, max(1, int(args.db_tail)))
except Exception as e:
print("\n--dump-db 失败(可忽略):", type(e).__name__, e, file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())