mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +08:00
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:
parent
2b32d11f43
commit
d14e9d3596
103 changed files with 7574 additions and 1641 deletions
|
|
@ -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 \
|
||||
"$@"
|
||||
147
runtime/operations/scripts/clear_all_chat_sessions.py
Normal file
147
runtime/operations/scripts/clear_all_chat_sessions.py
Normal 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())
|
||||
71
runtime/operations/scripts/clear_postgres_chat_sessions.ps1
Normal file
71
runtime/operations/scripts/clear_postgres_chat_sessions.ps1
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
128
runtime/operations/scripts/cutover_sqlite_to_postgresql.ps1
Normal file
128
runtime/operations/scripts/cutover_sqlite_to_postgresql.ps1
Normal 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
|
||||
67
runtime/operations/scripts/cutover_sqlite_to_postgresql.sh
Normal file
67
runtime/operations/scripts/cutover_sqlite_to_postgresql.sh
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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] = [
|
||||
|
|
|
|||
251
runtime/operations/scripts/live_chat_probe.py
Normal file
251
runtime/operations/scripts/live_chat_probe.py
Normal 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())
|
||||
|
|
@ -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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
178
runtime/operations/scripts/smoke_admin_chat_postgres.py
Normal file
178
runtime/operations/scripts/smoke_admin_chat_postgres.py
Normal 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())
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()}")
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue