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,20 @@
#!/usr/bin/env bash
# Import assistant SQLite (db_path) into PostgreSQL. Prerequisite: alembic upgrade head on target PG.
#
# Reads _local/system.env when present (--load-system-env). Target URL: AIA_ASSISTANT_DATABASE_URL
# (or pass extra args, e.g. --pg-url 'postgresql+psycopg://...' --dry-run).
#
# Usage:
# chmod +x runtime/operations/scripts/assistant_import_sqlite_to_postgresql.sh
# export AIA_ASSISTANT_DATABASE_URL='postgresql+psycopg://user:pass@host:5432/oclaw'
# ./runtime/operations/scripts/assistant_import_sqlite_to_postgresql.sh --dry-run
# ./runtime/operations/scripts/assistant_import_sqlite_to_postgresql.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT"
export PYTHONPATH="${ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
exec python "${ROOT}/runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py" \
--load-system-env \
--sqlite-from-db-path \
"$@"

View file

@ -0,0 +1,147 @@
"""Delete every row in ``chat_session`` (and session-bound helper rows).
Uses the same assistant store as the gateway (SQLite or PostgreSQL per env).
Requires ``--yes`` **and** environment ``AIA_CONFIRM_CHAT_SESSION_WIPE=1`` to avoid accidental wipes.
PostgreSQL (force, avoids wiping SQLite by mistake)::
set AIA_CONFIRM_CHAT_SESSION_WIPE=1
python runtime/operations/scripts/clear_all_chat_sessions.py --yes --postgresql
Or use ``runtime/operations/scripts/clear_postgres_chat_sessions.ps1`` (loads ``_local/system.env`` then runs the above).
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Any
def _exec(store: Any, sql: str) -> None:
with store._connect() as conn:
conn.execute(sql)
def _try_exec(store: Any, sql: str) -> bool:
try:
_exec(store, sql)
return True
except Exception:
return False
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--yes",
action="store_true",
help="Confirm destructive delete of all chat sessions.",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Only print how many sessions exist; do not delete.",
)
p.add_argument(
"--postgresql",
action="store_true",
help="After loading env, force AIA_ASSISTANT_DB_BACKEND=postgresql and abort unless the store is PG.",
)
args = p.parse_args()
if not args.yes and not args.dry_run:
print("Refusing to run without --yes (or use --dry-run to count only).", file=sys.stderr)
return 2
if args.yes and not args.dry_run and str(os.getenv("AIA_CONFIRM_CHAT_SESSION_WIPE") or "").strip().lower() not in {
"1",
"true",
"yes",
"on",
}:
print(
"Refusing destructive wipe: set environment AIA_CONFIRM_CHAT_SESSION_WIPE=1 together with --yes.",
file=sys.stderr,
)
return 2
try:
from interfaces.http.fastapi_app import load_system_env
load_system_env()
except Exception:
pass
if args.postgresql:
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "postgresql"
from svc.persistence.assistant_store import get_assistant_store, reset_assistant_store_singleton
if args.postgresql:
reset_assistant_store_singleton()
store = get_assistant_store()
if args.postgresql and not bool(getattr(store, "_use_pg", False)):
print(
"error: --postgresql was set but assistant store is not PostgreSQL "
"(check AIA_ASSISTANT_DATABASE_URL / OPS_ASSISTANT_DATABASE_URL).",
file=sys.stderr,
)
return 2
n0 = int(store.count_sessions() or 0)
print(f"session_count_before={n0}")
if args.dry_run:
return 0
if n0 <= 0:
print("nothing_to_do")
return 0
# Rows that reference sessions but are not always ON DELETE CASCADE across backends.
for sql in (
"DELETE FROM trace_event",
"DELETE FROM agent_eval_log",
"DELETE FROM oclaw_attempt",
"DELETE FROM oclaw_run",
"DELETE FROM oclaw_task",
"DELETE FROM memory_vector WHERE memory_id IN (SELECT memory_id FROM memory_item WHERE session_id IN (SELECT id FROM chat_session))",
"DELETE FROM memory_item WHERE session_id IN (SELECT id FROM chat_session)",
"DELETE FROM memory_hit_log WHERE session_id IN (SELECT id FROM chat_session)",
):
if _try_exec(store, sql):
print(f"ok_stmt={sql[:72]}...")
else:
print(f"skip_stmt={sql[:72]}...")
deleted_bulk = 0
try:
with store._connect() as conn:
cur = conn.execute("DELETE FROM chat_session")
deleted_bulk = int(getattr(cur, "rowcount", 0) or 0)
except Exception as exc:
print(f"bulk_delete_chat_session_failed={exc!r}; falling back to per-session delete")
batch = 0
while True:
rows = store.list_sessions(limit=400, offset=0)
if not rows:
break
for s in rows:
store.delete_session(str(s.id))
batch += 1
if batch > 1_000_000:
print("abort_loop_guard", file=sys.stderr)
return 1
deleted_bulk = batch
n1 = int(store.count_sessions() or 0)
print(f"deleted_sessions_bulk={deleted_bulk}")
print(f"session_count_after={n1}")
try:
store._chat_messages_repo().delete_messages_where_session_missing()
store._tool_log_queries_repo().delete_tool_logs_where_session_missing()
except Exception as exc:
print(f"orphan_cleanup_note={exc!r}")
return 0 if n1 == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,71 @@
# Wipe all chat_session rows (and related session-bound rows) on PostgreSQL only.
# Loads _local/system.env into the process, forces PG backend, sets confirmation env.
# Usage (from repo root is fine):
# .\runtime\operations\scripts\clear_postgres_chat_sessions.ps1
# Dry-run (count only):
# .\runtime\operations\scripts\clear_postgres_chat_sessions.ps1 -DryRun
param(
[switch]$DryRun = $false
)
$ErrorActionPreference = "Stop"
function Import-DotEnvFile([string]$path) {
if (-not (Test-Path $path)) { return }
Get-Content -LiteralPath $path -Encoding UTF8 | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith("#")) { return }
$idx = $line.IndexOf("=")
if ($idx -lt 1) { return }
$k = $line.Substring(0, $idx).Trim()
$v = $line.Substring($idx + 1).Trim()
if ($k) {
[System.Environment]::SetEnvironmentVariable($k, $v, "Process")
}
}
}
$repoRoot = $null
$cur = (Resolve-Path $PSScriptRoot).Path
for ($i = 0; $i -lt 16; $i++) {
if (Test-Path (Join-Path $cur "oclaw.json")) {
$repoRoot = $cur
break
}
$parent = Split-Path -Parent $cur
if (-not $parent -or $parent -eq $cur) { break }
$cur = $parent
}
if (-not $repoRoot) {
throw "Could not find oclaw.json above $PSScriptRoot"
}
Set-Location $repoRoot
$env:PYTHONPATH = $repoRoot
$envFile = Join-Path $repoRoot "_local\system.env"
Import-DotEnvFile $envFile
$env:AIA_ASSISTANT_DB_BACKEND = "postgresql"
$env:AIA_CONFIRM_CHAT_SESSION_WIPE = "1"
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
$pythonExe = $(if (Test-Path $venvPython) { $venvPython } else { "python" })
$args = @(
(Join-Path $repoRoot "runtime\operations\scripts\clear_all_chat_sessions.py")
)
if ($DryRun) {
$args += "--dry-run"
} else {
$args += "--yes"
}
$args += "--postgresql"
Write-Host "repo=$repoRoot" -ForegroundColor Cyan
Write-Host "env_file=$envFile" -ForegroundColor DarkGray
Write-Host "python=$pythonExe" -ForegroundColor DarkGray
Write-Host "dry_run=$DryRun" -ForegroundColor DarkGray
& $pythonExe @args
exit $LASTEXITCODE

View file

@ -5,10 +5,11 @@ import sys
from svc.config.paths import db_path
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
def main() -> int:
store = SqliteStore(db_path())
store = get_assistant_store()
tenants = store.list_tenants(limit=1)
if tenants:
tenant_id = tenants[0]["id"]

View file

@ -0,0 +1,128 @@
<#
.SYNOPSIS
Backup assistant SQLite DB, optionally dry-run, then import into PostgreSQL (empty schema).
.DESCRIPTION
1) Resolves SQLite path (explicit -SqlitePath or via Python db_path() / AIA_ASSISTANT_DB_PATH).
2) Copies the file to data/pg_cutover_backups/ (or -BackupDir).
3) Runs migrate_assistant_sqlite_to_postgresql.py --dry-run unless -SkipDryRun.
4) Runs the same script without --dry-run unless -DryRunOnly.
Set -PgUrl and/or ensure AIA_ASSISTANT_DATABASE_URL is set; with -LoadSystemEnv, URL may come only from _local/system.env (then -PgUrl can be omitted).
.EXAMPLE
.\cutover_sqlite_to_postgresql.ps1 -PgUrl "postgresql+psycopg://postgres:PASS@127.0.0.1:5432/oclaw"
.EXAMPLE
$env:AIA_ASSISTANT_DATABASE_URL = "postgresql+psycopg://..."
.\cutover_sqlite_to_postgresql.ps1
.EXAMPLE
.\cutover_sqlite_to_postgresql.ps1 -SqlitePath "D:\data\ai_ops.sqlite" -PgUrl "postgresql://..." -DryRunOnly
#>
[CmdletBinding()]
param(
[string] $PgUrl = "",
[string] $SqlitePath = "",
[string] $BackupDir = "",
[switch] $LoadSystemEnv,
[switch] $SkipDryRun,
[switch] $DryRunOnly,
[switch] $NoBackup
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
Set-Location $RepoRoot
if ($LoadSystemEnv) {
$envFile = Join-Path $RepoRoot "_local\system.env"
if (Test-Path -LiteralPath $envFile) {
Get-Content -LiteralPath $envFile | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith("#")) { return }
$i = $line.IndexOf("=")
if ($i -lt 1) { return }
$k = $line.Substring(0, $i).Trim()
$v = $line.Substring($i + 1).Trim()
if ($k) { Set-Item -Path "Env:$k" -Value $v }
}
Write-Host "Loaded _local/system.env into process environment."
}
else {
Write-Warning "LoadSystemEnv specified but $envFile not found."
}
}
if (-not $PgUrl) {
$PgUrl = $env:AIA_ASSISTANT_DATABASE_URL
}
if (-not $PgUrl -and -not $LoadSystemEnv) {
throw "Provide -PgUrl, set AIA_ASSISTANT_DATABASE_URL, or use -LoadSystemEnv (PostgreSQL URL in _local/system.env)."
}
if (-not $SqlitePath) {
$env:_OC_REPO_ROOT_FOR_PY = $RepoRoot
try {
$SqlitePath = (& python -c "import os,sys; sys.path.insert(0, os.environ['_OC_REPO_ROOT_FOR_PY']); from svc.config.paths import db_path; print(db_path(), end='')").Trim()
}
finally {
Remove-Item Env:_OC_REPO_ROOT_FOR_PY -ErrorAction SilentlyContinue
}
if (-not $SqlitePath) { throw "Could not resolve SQLite path via db_path()." }
Write-Host "Resolved SQLite: $SqlitePath"
}
if (-not (Test-Path -LiteralPath $SqlitePath)) {
throw "SQLite file not found: $SqlitePath"
}
$resolvedSqlite = (Resolve-Path -LiteralPath $SqlitePath).Path
$migrate = Join-Path $RepoRoot "runtime\operations\scripts\migrate_assistant_sqlite_to_postgresql.py"
if (-not (Test-Path -LiteralPath $migrate)) {
throw "Migration script not found: $migrate"
}
if (-not $BackupDir) {
$BackupDir = Join-Path $RepoRoot "data\pg_cutover_backups"
}
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
$leaf = [System.IO.Path]::GetFileNameWithoutExtension($resolvedSqlite)
$bakName = "${leaf}_pre_pg_${stamp}.sqlite"
$bakPath = Join-Path $BackupDir $bakName
if (-not $NoBackup) {
Copy-Item -LiteralPath $resolvedSqlite -Destination $bakPath -Force
Write-Host "Backup written: $bakPath"
}
else {
Write-Warning "NoBackup: skipping file copy (no SQLite backup created)."
}
$common = @($migrate)
if ($LoadSystemEnv) {
$common += "--load-system-env"
}
$common += "--sqlite", $resolvedSqlite
if ($PgUrl) {
$common += @("--pg-url", $PgUrl)
}
if (-not $SkipDryRun) {
Write-Host "=== Dry-run (row counts, no PG writes) ===" -ForegroundColor Cyan
& python @common "--dry-run"
if ($LASTEXITCODE -ne 0) { throw "Dry-run failed (exit $LASTEXITCODE)." }
}
if ($DryRunOnly) {
Write-Host "DryRunOnly: skipping live import." -ForegroundColor Yellow
exit 0
}
Write-Host "=== Live import into PostgreSQL ===" -ForegroundColor Cyan
& python @common
if ($LASTEXITCODE -ne 0) { throw "Migration failed (exit $LASTEXITCODE)." }
Write-Host ""
Write-Host "Done. Next: set AIA_ASSISTANT_DB_BACKEND=postgresql and AIA_ASSISTANT_DATABASE_URL in deployment, restart gateway." -ForegroundColor Green

View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Backup SQLite assistant DB, optional dry-run, then import into PostgreSQL (empty schema recommended).
#
# Loads _local/system.env via the Python migrator (--load-system-env) so AIA_ASSISTANT_DATABASE_URL /
# AIA_ASSISTANT_DB_PATH match the gateway. Override URL with extra args, e.g. --pg-url 'postgresql+...'
#
# Environment:
# SQLITE_PATH optional; default: db_path() after load_system_env
# BACKUP_DIR optional; default: <repo>/data/pg_cutover_backups
# NO_BACKUP=1 skip file copy
# SKIP_DRY_RUN=1 skip dry-run pass
# DRY_RUN_ONLY=1 only dry-run
#
# Example:
# chmod +x runtime/operations/scripts/cutover_sqlite_to_postgresql.sh
# ./runtime/operations/scripts/cutover_sqlite_to_postgresql.sh
# ./runtime/operations/scripts/cutover_sqlite_to_postgresql.sh --pg-url 'postgresql+psycopg://u:p@h:5432/oclaw'
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT"
export PYTHONPATH="${ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
MIG="${ROOT}/runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py"
resolve_sqlite() {
if [[ -n "${SQLITE_PATH:-}" ]]; then
printf '%s' "$SQLITE_PATH"
return
fi
python -c "import os,sys; sys.path.insert(0, r'''${ROOT}'''); os.chdir(r'''${ROOT}'''); from svc.config.bootstrap_env import load_system_env; load_system_env(force=True); from svc.config.paths import db_path; print(db_path(), end='')"
}
SQLITE="$(resolve_sqlite)"
if [[ ! -f "$SQLITE" ]]; then
echo "SQLite file not found: $SQLITE" >&2
exit 1
fi
BACKUP_DIR="${BACKUP_DIR:-${ROOT}/data/pg_cutover_backups}"
mkdir -p "$BACKUP_DIR"
STAMP="$(date +%Y%m%d_%H%M%S)"
BASE="$(basename "$SQLITE" .sqlite)"
BAK="${BACKUP_DIR}/${BASE}_pre_pg_${STAMP}.sqlite"
if [[ "${NO_BACKUP:-}" != "1" ]]; then
cp -f "$SQLITE" "$BAK"
echo "Backup written: $BAK"
else
echo "NO_BACKUP=1: skipping SQLite file backup." >&2
fi
COMMON=(--load-system-env --sqlite "$SQLITE")
if [[ "${SKIP_DRY_RUN:-}" != "1" ]]; then
echo "=== Dry-run (row counts, no PG writes) ===" >&2
python "$MIG" "${COMMON[@]}" "$@" --dry-run
fi
if [[ "${DRY_RUN_ONLY:-}" == "1" ]]; then
echo "DRY_RUN_ONLY=1: skipping live import." >&2
exit 0
fi
echo "=== Live import into PostgreSQL ===" >&2
python "$MIG" "${COMMON[@]}" "$@"
echo "" >&2
echo "Done. Set AIA_ASSISTANT_DB_BACKEND=postgresql and AIA_ASSISTANT_DATABASE_URL, then restart the gateway." >&2

View file

@ -13,6 +13,7 @@ sys.path.insert(0, str(ROOT))
from svc.config.paths import db_path # noqa: E402
from svc.persistence.sqlite_store import SqliteStore # noqa: E402
from svc.persistence.assistant_store import get_assistant_store
from runtime.tools.mcp.installer import McpServerManifest, _safe_server_id, install_mcp_server # noqa: E402
@ -24,7 +25,7 @@ BATCH: list[tuple[str, str, list[str]]] = [
def main() -> None:
store = SqliteStore(db_path())
store = get_assistant_store()
for seed, source_ref, entry_args in BATCH:
server_id = _safe_server_id(seed)
manifest = McpServerManifest(

View file

@ -16,6 +16,7 @@ sys.path.insert(0, str(ROOT))
from svc.config.paths import db_path # noqa: E402
from svc.persistence.sqlite_store import SqliteStore # noqa: E402
from svc.persistence.assistant_store import get_assistant_store
from runtime.tools.mcp.installer import McpServerManifest, install_mcp_server # noqa: E402
from runtime.tools.mcp.runtime import McpProcessRuntime # noqa: E402
@ -85,7 +86,7 @@ def _append_generalist_binding(store: SqliteStore, server_id: str) -> None:
def main() -> None:
store = SqliteStore(db_path())
store = get_assistant_store()
manifest = McpServerManifest(
server_id="mcp-context7",
source_type="npm",

View file

@ -15,6 +15,7 @@ sys.path.insert(0, str(ROOT))
from svc.config.paths import db_path # noqa: E402
from svc.persistence.sqlite_store import SqliteStore # noqa: E402
from svc.persistence.assistant_store import get_assistant_store
from runtime.tools.mcp.installer import McpServerManifest, install_mcp_server # noqa: E402
from runtime.tools.mcp.runtime import McpProcessRuntime # noqa: E402
@ -61,7 +62,7 @@ def _sync_tools(store: SqliteStore, server_id: str) -> bool:
def main() -> None:
store = SqliteStore(db_path())
store = get_assistant_store()
db_file = db_path()
bundles: list[McpServerManifest] = [

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())

View file

@ -0,0 +1,407 @@
"""Copy assistant data from SQLite (db_path file) into PostgreSQL (schema from Alembic / bootstrap).
**Prerequisite:** target PostgreSQL already has schema (``alembic upgrade head`` or
``svc/persistence/ddl/postgresql_bootstrap.sql``). This script copies **data only**.
Tables are copied in **foreign-key safe order** (from SQLite ``PRAGMA foreign_key_list``), and only
**columns present in both** SQLite and PostgreSQL are inserted.
By default the script aborts if any target table in ``public`` already has rows (empty PG only).
Use ``--allow-non-empty`` to skip that check (you are responsible for avoiding duplicates / FK errors).
**PostgreSQL URL** is taken from ``--pg-url`` if set; otherwise from the first non-empty environment
variable among ``AIA_ASSISTANT_DATABASE_URL``, ``OPS_ASSISTANT_DATABASE_URL``, ``AIA_ASSISTANT_PG_DSN``,
``OPS_ASSISTANT_PG_DSN``. Use ``--load-system-env`` to merge ``_local/system.env`` first (same as the
HTTP gateway).
**Open-source / headless device (Linux example)**::
export AIA_ASSISTANT_DATABASE_URL='postgresql+psycopg://USER:PASS@HOST:5432/oclaw'
export AIA_ASSISTANT_DB_PATH=/var/lib/oclaw/data/ai_ops.sqlite # optional; default data/ai_ops.sqlite under repo
cd /path/to/oclaw && PYTHONPATH=. python runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py \\
--load-system-env --sqlite-from-db-path --dry-run
# then same without --dry-run
Or use the wrapper script ``runtime/operations/scripts/assistant_import_sqlite_to_postgresql.sh``.
**Explicit paths**::
python runtime/operations/scripts/migrate_assistant_sqlite_to_postgresql.py \\
--sqlite data/ai_ops.sqlite \\
--pg-url postgresql+psycopg://postgres:pass@127.0.0.1:5432/oclaw
"""
from __future__ import annotations
import argparse
import os
import re
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any, Iterable
import psycopg
from psycopg.rows import dict_row
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from svc.persistence.pg_adapter import normalize_psycopg_conninfo
def _pg_row_first_value(row: Any) -> Any:
if row is None:
raise ValueError("expected a row")
if isinstance(row, dict):
return next(iter(row.values()))
return row[0]
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\Z")
def _require_ident(name: str) -> str:
if not _IDENT.fullmatch(name):
raise ValueError(f"invalid SQL identifier: {name!r}")
return name
def _sqlite_user_tables(sl: sqlite3.Connection) -> list[str]:
rows = sl.execute(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
"""
).fetchall()
return [str(r[0]) for r in rows]
def _pg_public_tables(pg: psycopg.Connection) -> set[str]:
with pg.cursor() as cur:
cur.execute(
"""
SELECT tablename FROM pg_catalog.pg_tables
WHERE schemaname = 'public'
"""
)
return {str(_pg_row_first_value(r)) for r in cur.fetchall()}
def _fk_parents_for_table(sl: sqlite3.Connection, table: str) -> set[str]:
t = _require_ident(table)
rows = sl.execute(f"PRAGMA foreign_key_list({t})").fetchall()
out: set[str] = set()
for r in rows:
# (id, seq, table, from, to, on_update, on_delete, match)
ref = str(r[2])
if ref:
out.add(ref)
return out
def _topological_sort(nodes: list[str], parents: dict[str, set[str]]) -> list[str]:
"""``parents[t]`` = tables that must be copied *before* ``t`` (referenced by FK)."""
node_set = list(nodes)
seen = set(node_set)
if len(seen) != len(node_set):
raise ValueError("duplicate table in migration list")
children: dict[str, list[str]] = defaultdict(list)
indegree: dict[str, int] = {}
for t in node_set:
ps = parents.get(t, set()) & seen
indegree[t] = len(ps)
for p in ps:
children[p].append(t)
for ch in children.values():
ch.sort()
queue = sorted([t for t in node_set if indegree[t] == 0])
out: list[str] = []
while queue:
n = queue.pop(0)
out.append(n)
for c in children[n]:
indegree[c] -= 1
if indegree[c] == 0:
queue.append(c)
queue.sort()
if len(out) != len(seen):
remain = seen - set(out)
raise SystemExit(
"Cannot derive a foreign-key-safe copy order (cycle or unresolved FK). "
f"Remaining tables: {sorted(remain)}"
)
return out
def _migration_order(sl: sqlite3.Connection, tables: list[str]) -> list[str]:
node_set = list(tables)
parents = {t: _fk_parents_for_table(sl, t) & set(node_set) for t in node_set}
return _topological_sort(node_set, parents)
def _sqlite_columns(sl: sqlite3.Connection, table: str) -> list[str]:
t = _require_ident(table)
rows = sl.execute(f"PRAGMA table_info({t})").fetchall()
# cid, name, type, notnull, dflt_value, pk
return [str(r[1]) for r in rows]
def _pg_columns(pg: psycopg.Connection, table: str) -> set[str]:
t = _require_ident(table)
with pg.cursor() as cur:
cur.execute(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
""",
(t,),
)
return {str(_pg_row_first_value(r)) for r in cur.fetchall()}
def _common_columns(sl: sqlite3.Connection, pg: psycopg.Connection, table: str) -> list[str]:
sc = _sqlite_columns(sl, table)
pc = _pg_columns(pg, table)
return [c for c in sc if c in pc and _IDENT.fullmatch(c)]
def _assert_pg_tables_empty(pg: psycopg.Connection, tables: Iterable[str]) -> None:
with pg.cursor() as cur:
for t in sorted(set(tables)):
_require_ident(t)
cur.execute(f'SELECT COUNT(*) AS n FROM "{t}"')
row = cur.fetchone()
n = int(_pg_row_first_value(row))
if n:
raise SystemExit(
f"Refusing to import: PostgreSQL table {t!r} already has {n} row(s). "
"Use an empty schema after alembic upgrade, or pass --allow-non-empty if you "
"really intend to append (duplicates / FK failures are your risk)."
)
def _sqlite_row_counts(sl: sqlite3.Connection, tables: Iterable[str]) -> dict[str, int]:
out: dict[str, int] = {}
for t in tables:
_require_ident(t)
n = int(sl.execute(f"SELECT COUNT(*) FROM {_require_ident(t)}").fetchone()[0])
out[t] = n
return out
def _pg_row_counts(pg: psycopg.Connection, tables: Iterable[str]) -> dict[str, int]:
out: dict[str, int] = {}
with pg.cursor() as cur:
for t in tables:
_require_ident(t)
cur.execute(f'SELECT COUNT(*) AS n FROM "{t}"')
row = cur.fetchone()
out[t] = int(_pg_row_first_value(row))
return out
def _copy_table(
*,
sl: sqlite3.Connection,
pg: psycopg.Connection,
table: str,
cols: list[str],
dry_run: bool,
batch: int,
) -> int:
if not cols:
return 0
t = _require_ident(table)
cur = sl.execute(f"SELECT {', '.join(_require_ident(c) for c in cols)} FROM {t}")
rows = cur.fetchall()
if not rows:
return 0
if dry_run:
return len(rows)
col_sql = ", ".join(f'"{_require_ident(c)}"' for c in cols)
placeholders = ", ".join(["%s"] * len(cols))
sql = f'INSERT INTO "{t}" ({col_sql}) VALUES ({placeholders})'
tuples = [tuple(r[c] for c in cols) for r in rows]
with pg.cursor() as pc:
for i in range(0, len(tuples), max(1, batch)):
chunk = tuples[i : i + max(1, batch)]
pc.executemany(sql, chunk)
return len(rows)
def _serial_columns(pg: psycopg.Connection) -> list[tuple[str, str]]:
"""Tables/columns backed by a PostgreSQL sequence (BIGSERIAL etc.), for post-import setval."""
with pg.cursor() as cur:
cur.execute(
"""
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_default IS NOT NULL
AND column_default LIKE 'nextval%'
ORDER BY table_name, column_name
"""
)
rows = cur.fetchall()
out: list[tuple[str, str]] = []
for r in rows:
if isinstance(r, dict):
t = str(r["table_name"])
c = str(r["column_name"])
else:
t = str(r[0])
c = str(r[1])
if t and c:
out.append((_require_ident(t), _require_ident(c)))
return out
def _sync_sequences(pg: psycopg.Connection) -> None:
for t, col in _serial_columns(pg):
with pg.cursor() as cur:
cur.execute(f'SELECT COALESCE(MAX("{col}"), 1) AS mx FROM "{t}"')
row = cur.fetchone()
mx = int(_pg_row_first_value(row))
try:
cur.execute(
"SELECT setval(pg_get_serial_sequence(%s, %s), %s, true)",
(t, col, mx),
)
except Exception:
pass
def _pg_url_from_environ() -> str:
return (
os.getenv("AIA_ASSISTANT_DATABASE_URL")
or os.getenv("OPS_ASSISTANT_DATABASE_URL")
or os.getenv("AIA_ASSISTANT_PG_DSN")
or os.getenv("OPS_ASSISTANT_PG_DSN")
or ""
).strip()
def _resolve_sqlite_path(arg: str | None, use_db_path: bool) -> Path:
if use_db_path:
from svc.config.paths import db_path
return Path(db_path()).expanduser().resolve()
if not arg:
raise SystemExit("Either pass --sqlite PATH or --sqlite-from-db-path")
return Path(arg).expanduser().resolve()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--sqlite", default=None, help="Path to source SQLite assistant DB")
ap.add_argument(
"--sqlite-from-db-path",
action="store_true",
help="Use db_path() from env (AIA_ASSISTANT_DB_PATH / default) as SQLite source",
)
ap.add_argument(
"--load-system-env",
action="store_true",
help="Merge _local/system.env into the process (for DB_PATH / DATABASE_URL on devices)",
)
ap.add_argument(
"--pg-url",
default=None,
help="Target PostgreSQL URL; if omitted, use AIA_ASSISTANT_DATABASE_URL (or OPS_* / *_PG_DSN)",
)
ap.add_argument("--dry-run", action="store_true", help="Count rows only; do not write to PG")
ap.add_argument(
"--allow-non-empty",
action="store_true",
help="Do not abort when target PG tables already contain rows",
)
ap.add_argument(
"--batch",
type=int,
default=500,
help="Rows per executemany batch (default 500)",
)
args = ap.parse_args()
if bool(args.load_system_env):
from svc.config.bootstrap_env import load_system_env
load_system_env(force=True)
sqlite_path = _resolve_sqlite_path(args.sqlite, bool(args.sqlite_from_db_path))
if not sqlite_path.is_file():
raise SystemExit(f"sqlite file not found: {sqlite_path}")
raw_pg = (args.pg_url or "").strip() or _pg_url_from_environ()
if not raw_pg:
raise SystemExit(
"No PostgreSQL URL: pass --pg-url or set one of "
"AIA_ASSISTANT_DATABASE_URL, OPS_ASSISTANT_DATABASE_URL, "
"AIA_ASSISTANT_PG_DSN, OPS_ASSISTANT_PG_DSN (use --load-system-env to read _local/system.env)."
)
pg_url = normalize_psycopg_conninfo(raw_pg)
sl = sqlite3.connect(str(sqlite_path))
sl.row_factory = sqlite3.Row
try:
sl.execute("PRAGMA foreign_keys=ON")
except Exception:
pass
pg = psycopg.connect(pg_url, row_factory=dict_row, autocommit=False)
try:
sqlite_tables = _sqlite_user_tables(sl)
pg_tables = _pg_public_tables(pg)
common = [t for t in sqlite_tables if t in pg_tables]
skipped_sqlite = [t for t in sqlite_tables if t not in pg_tables]
if skipped_sqlite:
print("skip (not in PG public schema):", ", ".join(skipped_sqlite))
if not common:
raise SystemExit("No common tables between SQLite and PostgreSQL; nothing to copy.")
order = _migration_order(sl, common)
if not args.dry_run and not args.allow_non_empty:
_assert_pg_tables_empty(pg, common)
src_counts = _sqlite_row_counts(sl, order)
total = 0
copied: list[str] = []
for t in order:
cols = _common_columns(sl, pg, t)
if not cols:
n0 = src_counts.get(t, 0)
if n0 > 0:
print(f"{t}: SKIP (no common columns; sqlite has {n0} rows — schema drift)")
continue
n = _copy_table(sl=sl, pg=pg, table=t, cols=cols, dry_run=bool(args.dry_run), batch=int(args.batch))
print(f"{t}: {n} rows ({len(cols)} columns)")
total += n
copied.append(t)
if not args.dry_run:
pg.commit()
_sync_sequences(pg)
pg.commit()
verify = _pg_row_counts(pg, order)
bad = [t for t in copied if verify.get(t, 0) != src_counts.get(t, 0)]
if bad:
print("WARNING: row count mismatch PG vs SQLite for:", ", ".join(bad))
for t in bad:
print(f" {t}: sqlite={src_counts.get(t, 0)} pg={verify.get(t, 0)}")
else:
print("verify: row counts match SQLite for all copied tables")
print("total rows (copied or dry-run counted):", total)
except BaseException:
pg.rollback()
raise
finally:
sl.close()
pg.close()
if __name__ == "__main__":
main()

View file

@ -17,6 +17,7 @@ sys.path.insert(0, str(ROOT))
from svc.config.paths import PROJECT_ROOT, db_path # noqa: E402
from svc.persistence.sqlite_store import SqliteStore # noqa: E402
from svc.persistence.assistant_store import get_assistant_store
from runtime.tools.mcp.installer import McpServerManifest, _safe_server_id, install_mcp_server # noqa: E402
@ -38,7 +39,7 @@ def main(argv: list[str]) -> int:
print("no servers[] in seed file", file=sys.stderr)
return 2
store = SqliteStore(db_path())
store = get_assistant_store()
ok_n = 0
for payload in items:
if not isinstance(payload, dict):

View file

@ -4,6 +4,7 @@ import sys
from svc.config.paths import db_path
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
def _to_bool(v: str) -> str:
@ -20,7 +21,7 @@ def main() -> int:
return 2
cmd = args[0].lower()
store = SqliteStore(db_path())
store = get_assistant_store()
if cmd == "show":
enabled = str(store.get_setting("wecom_auto_bind_enabled") or "1").strip()

View file

@ -4,6 +4,7 @@ import sys
from svc.config.paths import db_path
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
def main() -> int:
@ -13,7 +14,7 @@ def main() -> int:
print(" python -m scripts.set_wecom_config <bot_id> <bot_secret>")
return 2
store = SqliteStore(db_path())
store = get_assistant_store()
if len(args) < 2:
print("error=missing_required_args")
return 2

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())

View file

@ -3,7 +3,9 @@
[int]$Port = 8787,
[switch]$SkipInstall = $false,
[switch]$Background = $false,
[bool]$WithWikiWorker = $true
[bool]$WithWikiWorker = $true,
# Foreground: mirror merged stdout/stderr to gateway.foreground.log (same dir as start_service logs).
[switch]$NoLogMirror = $false
)
$ErrorActionPreference = "Stop"
@ -75,6 +77,18 @@ if (-not $SkipInstall) {
Write-Step "Skip dependency install"
}
# Same directory as runtime.operations.runtime.start_service (db_path parent / logs, or AIA_RUNTIME_LOG_DIR).
$logDir = $null
try {
$logDir = (& $pythonExe -c "from runtime.operations.runtime import assistant_runtime_log_dir; print(str(assistant_runtime_log_dir()))" 2>$null | Select-Object -Last 1).Trim()
} catch { }
if (-not $logDir) {
$logDir = Join-Path $repoRoot "data\logs"
}
Write-Step "Runtime log dir: $logDir"
Write-Host " (stack up / start_service: gateway.err.log + gateway.out.log here)" -ForegroundColor DarkGray
Write-Host " (this script foreground: gateway.foreground.log when log mirror on)" -ForegroundColor DarkGray
Write-Host ""
Write-Step "Gateway URL"
Write-Host "Admin: http://$BindHost`:$Port/admin"
@ -95,7 +109,15 @@ if ($WithWikiWorker) {
if ($Background) {
Write-Step "Starting gateway in background"
$p = Start-Process -FilePath $pythonExe -ArgumentList @("-m","runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$errLog = Join-Path $logDir "gateway.err.log"
$outLog = Join-Path $logDir "gateway.out.log"
Write-Host "stderr -> $errLog" -ForegroundColor DarkGray
Write-Host "stdout -> $outLog" -ForegroundColor DarkGray
$p = Start-Process -FilePath $pythonExe `
-ArgumentList @("-m","runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) `
-WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden `
-RedirectStandardError $errLog -RedirectStandardOutput $outLog
Set-Content -Path $pidFile -Value "$($p.Id)" -Encoding ascii
Write-Host "gateway.pid = $pidFile" -ForegroundColor DarkGray
Write-Host "PID = $($p.Id)" -ForegroundColor Green
@ -103,7 +125,21 @@ if ($Background) {
}
Write-Step "Starting gateway (foreground)"
& $pythonExe -m runtime.operations gateway start --host $BindHost --port $Port
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
# Uvicorn writes INFO to stderr. PowerShell 7 wraps native stderr as ErrorRecord
# (red "NativeCommandError") even when non-terminating. Run under cmd.exe with
# ``2>&1`` so PS only sees a single stdout stream of plain text.
if ($BindHost -match '[&|`~]' -or $Port -lt 1 -or $Port -gt 65535) {
Fail "Invalid -BindHost or -Port for gateway launcher."
}
$cmdLine = "cd /d `"$repoRoot`" && `"$pythonExe`" -m runtime.operations gateway start --host $BindHost --port $Port 2>&1"
if (-not $NoLogMirror) {
$fgLog = Join-Path $logDir "gateway.foreground.log"
Write-Host "Mirroring console to: $fgLog (pass -NoLogMirror to disable)" -ForegroundColor DarkGray
cmd.exe /d /s /c $cmdLine | Tee-Object -FilePath $fgLog -Append
} else {
cmd.exe /d /s /c $cmdLine
}

View file

@ -4,6 +4,7 @@ import sys
from svc.config.paths import db_path
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
_CLEAR_KEYS = [
@ -29,7 +30,7 @@ def main() -> int:
print("error=missing_required_args")
return 2
store = SqliteStore(db_path())
store = get_assistant_store()
store.set_setting("wecom_mode", "bot_api")
store.set_setting("wecom_bot_id", bot_id)
store.set_secret("wecom_bot_secret", bot_secret)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from svc.config.paths import db_path
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
_CLEAR_KEYS = [
@ -24,7 +25,7 @@ _CLEAR_KEYS = [
def main() -> int:
store = SqliteStore(db_path())
store = get_assistant_store()
with store._connect() as conn: # internal cleanup script; safe to use store connection helper
cur_ident = conn.execute("DELETE FROM channel_identity WHERE channel = ?", ("wecom",))
ident_deleted = int(cur_ident.rowcount or 0)

View file

@ -7,10 +7,11 @@ import uuid
from svc.config.paths import db_path
from svc.integrations.wecom_client import WeComClient
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
def main() -> int:
store = SqliteStore(db_path())
store = get_assistant_store()
client = WeComClient(store)
try:
bot_id, _bot_secret = client.get_bot_credentials()

View file

@ -6,6 +6,7 @@ from datetime import datetime, timezone
from svc.config.paths import db_path
from svc.integrations.wecom_client import WeComClient
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
def _fmt_ts(ts: str) -> str:
@ -17,7 +18,7 @@ def _fmt_ts(ts: str) -> str:
def main() -> int:
store = SqliteStore(db_path())
store = get_assistant_store()
client = WeComClient(store)
print("ok=1")
print(f"mode={client.mode()}")

View file

@ -13,6 +13,7 @@ if str(_REPO_ROOT) not in sys.path:
from svc.config.paths import PROJECT_ROOT, db_path
from svc.persistence.sqlite_store import SqliteStore
from svc.persistence.assistant_store import get_assistant_store
def _load_cfg() -> dict:
@ -44,7 +45,7 @@ def main() -> int:
cfg = _load_cfg()
wiki_root = _wiki_root_from_cfg(cfg)
store = SqliteStore(db_path())
store = get_assistant_store()
suffix = uuid.uuid4().hex[:8]
payload = {