mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
Expose sidecar status, start/stop, QR bind, and unbind so 405 session failures are visible instead of a stuck connecting state. Co-authored-by: Cursor <cursoragent@cursor.com>
4834 lines
215 KiB
Python
4834 lines
215 KiB
Python
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
from urllib import parse as urllib_parse
|
||
from urllib import request as urllib_request
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Body, Header, Query
|
||
from fastapi import HTTPException
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
from runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
||
from runtime.agents.factory import build_gateway_executor
|
||
from runtime.gateway import OclawGateway
|
||
from runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist
|
||
from runtime.operations.providers.registry import build_channel_registry
|
||
from runtime.operations.runtime import (
|
||
cleanup_service_processes_by_pid,
|
||
detect_orphan_service_processes,
|
||
is_pid_running,
|
||
list_listen_ports_for_pid,
|
||
list_service_process_pids,
|
||
status_services,
|
||
)
|
||
from runtime.operations.stack import cmd_stack_down, cmd_stack_status, cmd_stack_up
|
||
from runtime.orchestration.vector_store import read_vector_memory_runtime
|
||
from svc.config.paths import PROJECT_ROOT, db_path
|
||
from svc.config.passwords import load_expected_password
|
||
from svc.persistence.sqlite_store import SqliteStore
|
||
from svc.persistence.assistant_store import get_assistant_store
|
||
from runtime.agents.specialists import discover_specialist_ids, parse_agent_profile_bindings
|
||
from runtime.tools.mcp.env_config import mcp_runtime_for_row
|
||
from runtime.tools.mcp.cursor_config import (
|
||
config_payload_to_upsert_fields,
|
||
parse_cursor_mcp_document,
|
||
registry_row_to_cursor_server,
|
||
)
|
||
from runtime.tools.mcp.installer import (
|
||
_safe_server_id,
|
||
install_mcp_server,
|
||
preflight_mcp_server,
|
||
uninstall_mcp_server,
|
||
)
|
||
from runtime.tools.mcp.manifest import McpServerManifest
|
||
from runtime.operations.mcp_registry_export import (
|
||
build_mcp_install_export_document,
|
||
mcp_migrated_json_path,
|
||
persist_mcp_migrated_file,
|
||
)
|
||
from runtime.tools.mcp.filesystem_argv import build_mcp_process_command
|
||
from runtime.tools.mcp.registry import McpRegistry
|
||
from runtime.tools.mcp.runtime import McpProcessRuntime
|
||
from interfaces.admin.mcp_e2e_probe import build_mcp_e2e_probe_plans
|
||
from runtime.tools.catalog import default_registry
|
||
from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor
|
||
from svc.llm.chat_models import LLMToolCall
|
||
from runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES
|
||
from runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_error_codes
|
||
from runtime.prompt_prebuild import run_runtime_prewarm, runtime_prewarm_prompts_snapshot, runtime_prewarm_status
|
||
from runtime.workspaces.experts import (
|
||
create_expert,
|
||
delete_expert,
|
||
list_experts,
|
||
normalize_expert_id,
|
||
update_expert_files,
|
||
update_expert_meta,
|
||
)
|
||
|
||
_WECOM_CLEAR_KEYS = [
|
||
"wecom_mode",
|
||
"wecom_bot_id",
|
||
"wecom_bot_secret",
|
||
"wecom_corp_id",
|
||
"wecom_agent_id",
|
||
"wecom_agent_secret",
|
||
"wecom_access_token_cache",
|
||
"wecom_last_msg_ts",
|
||
"wecom_last_from_user",
|
||
"wecom_recent_from_users",
|
||
"wecom_last_cmd",
|
||
"wecom_last_unknown_cmd_payload",
|
||
"wecom_last_raw_body",
|
||
"wecom_last_raw_from_user",
|
||
"wecom_last_parse_error",
|
||
]
|
||
|
||
|
||
def _wecom_bot_secret_key(tenant_id: str, user_id: str, account_id: str) -> str:
|
||
return f"wecom:bot_secret:{tenant_id}:{user_id}:{account_id}"
|
||
|
||
|
||
def _expand_mcp_entry_args(raw: list[Any] | None) -> list[str]:
|
||
"""Expand ``__REPO_ROOT__/...`` in argv (same as ``scripts/seed_mcp_registry.py``)."""
|
||
out: list[str] = []
|
||
for x in raw or []:
|
||
s = str(x).strip()
|
||
if not s:
|
||
continue
|
||
if s.startswith("__REPO_ROOT__/"):
|
||
rel = s.replace("__REPO_ROOT__/", "", 1)
|
||
s = str((PROJECT_ROOT / rel).resolve())
|
||
out.append(s)
|
||
return out
|
||
|
||
|
||
def _mcp_health_and_sync_one(store: SqliteStore, row: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""Persist MCP tools/list for one server. Health is best-effort and does not gate sync."""
|
||
from runtime.tools.mcp.sync_tools import sync_mcp_server_tools
|
||
|
||
return sync_mcp_server_tools(store, row)
|
||
|
||
|
||
def _is_bailian_webparser_remote(*, entry_command: str, entry_args: list[str]) -> bool:
|
||
from runtime.tools.mcp.sync_tools import is_bailian_webparser_remote
|
||
|
||
return is_bailian_webparser_remote(entry_command=entry_command, entry_args=entry_args)
|
||
|
||
|
||
def _bailian_webparser_virtual_tools() -> list[dict[str, Any]]:
|
||
from runtime.tools.mcp.sync_tools import bailian_webparser_virtual_tools
|
||
|
||
return bailian_webparser_virtual_tools()
|
||
|
||
|
||
def _spawn_async_runtime_prewarm(*, reason: str) -> dict[str, Any]:
|
||
"""Fire-and-forget prewarm after Admin MCP catalog changes (does not block the HTTP response)."""
|
||
import threading
|
||
|
||
reason_s = str(reason or "mcp_admin_sync").strip() or "mcp_admin_sync"
|
||
|
||
def _run() -> None:
|
||
try:
|
||
run_runtime_prewarm(reason=reason_s, store=get_assistant_store())
|
||
except Exception:
|
||
pass
|
||
|
||
th = threading.Thread(target=_run, name="oclaw-mcp-sync-prewarm", daemon=True)
|
||
th.start()
|
||
return {"accepted": True, "mode": "async", "reason": reason_s}
|
||
|
||
|
||
def _http_get_json(url: str, *, timeout: float = 8.0) -> dict[str, Any]:
|
||
req = urllib_request.Request(
|
||
url,
|
||
headers={
|
||
"accept": "application/json",
|
||
"user-agent": "oclaw-admin-mcp-check/1.0",
|
||
},
|
||
method="GET",
|
||
)
|
||
with urllib_request.urlopen(req, timeout=timeout) as resp:
|
||
raw = resp.read().decode("utf-8", errors="ignore")
|
||
data = json.loads(raw) if raw else {}
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
|
||
def _github_repo_from_source_ref(source_ref: str) -> str:
|
||
s = str(source_ref or "").strip()
|
||
if not s:
|
||
return ""
|
||
m = re.search(r"github\.com[:/]+([^/]+)/([^/#]+)", s, re.I)
|
||
if m:
|
||
owner = str(m.group(1) or "").strip()
|
||
repo = str(m.group(2) or "").strip().removesuffix(".git")
|
||
return f"{owner}/{repo}" if owner and repo else ""
|
||
if re.fullmatch(r"[^/\s]+/[^/\s]+", s):
|
||
return s
|
||
return ""
|
||
|
||
|
||
def _detect_mcp_latest_version(source_type: str, source_ref: str) -> tuple[str, str]:
|
||
st = str(source_type or "").strip().lower()
|
||
sr = str(source_ref or "").strip()
|
||
if st == "npm":
|
||
pkg = urllib_parse.quote(sr, safe="@/")
|
||
data = _http_get_json(f"https://registry.npmjs.org/{pkg}")
|
||
tags = data.get("dist-tags") if isinstance(data.get("dist-tags"), dict) else {}
|
||
latest = str(tags.get("latest") or "").strip()
|
||
return latest, "npm:dist-tags.latest"
|
||
if st == "pypi":
|
||
pkg = urllib_parse.quote(sr, safe="")
|
||
data = _http_get_json(f"https://pypi.org/pypi/{pkg}/json")
|
||
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
||
latest = str(info.get("version") or "").strip()
|
||
return latest, "pypi:info.version"
|
||
if st == "github":
|
||
repo = _github_repo_from_source_ref(sr)
|
||
if not repo:
|
||
return "", "github:repo_parse_failed"
|
||
try:
|
||
data = _http_get_json(f"https://api.github.com/repos/{repo}/releases/latest")
|
||
latest = str(data.get("tag_name") or data.get("name") or "").strip()
|
||
if latest:
|
||
return latest, "github:releases.latest"
|
||
except Exception:
|
||
pass
|
||
data2 = _http_get_json(f"https://api.github.com/repos/{repo}/tags?per_page=1")
|
||
if isinstance(data2, list): # defensive; _http_get_json returns dict for objects
|
||
return "", "github:tags_empty"
|
||
# GitHub tags endpoint returns list; if parse failed to dict, try raw fetch.
|
||
req = urllib_request.Request(
|
||
f"https://api.github.com/repos/{repo}/tags?per_page=1",
|
||
headers={"accept": "application/json", "user-agent": "oclaw-admin-mcp-check/1.0"},
|
||
method="GET",
|
||
)
|
||
with urllib_request.urlopen(req, timeout=8.0) as resp:
|
||
arr_raw = resp.read().decode("utf-8", errors="ignore")
|
||
arr = json.loads(arr_raw) if arr_raw else []
|
||
if isinstance(arr, list) and arr:
|
||
top = arr[0] if isinstance(arr[0], dict) else {}
|
||
return str(top.get("name") or "").strip(), "github:tags[0]"
|
||
return "", "github:no_release_or_tag"
|
||
return "", "unsupported_source_type"
|
||
|
||
|
||
def _check_mcp_update_row(row: dict[str, Any]) -> dict[str, Any]:
|
||
sid = str(row.get("server_id") or "").strip()
|
||
source_type = str(row.get("source_type") or "").strip().lower()
|
||
source_ref = str(row.get("source_ref") or "").strip()
|
||
current_version = str(row.get("version") or "").strip()
|
||
out = {
|
||
"server_id": sid,
|
||
"source_type": source_type,
|
||
"source_ref": source_ref,
|
||
"current_version": current_version,
|
||
"latest_version": "",
|
||
"has_update": False,
|
||
"check_error": "",
|
||
"check_source": "",
|
||
}
|
||
try:
|
||
latest, src = _detect_mcp_latest_version(source_type, source_ref)
|
||
out["latest_version"] = latest
|
||
out["check_source"] = src
|
||
if latest and current_version and current_version not in {"latest", "*"} and current_version != latest:
|
||
out["has_update"] = True
|
||
except Exception as exc:
|
||
out["check_error"] = f"{type(exc).__name__}: {exc}"
|
||
return out
|
||
|
||
|
||
def _enrich_wecom_channel_account(store: SqliteStore, tenant_id: str, user_id: str, item: dict[str, Any]) -> dict[str, Any]:
|
||
aid = str(item.get("account_id") or "")
|
||
cfg = item.get("config")
|
||
if not isinstance(cfg, dict):
|
||
cfg = {}
|
||
out = dict(item)
|
||
out["has_bot_secret"] = bool(store.get_secret(_wecom_bot_secret_key(tenant_id, user_id, aid)))
|
||
out["wecom_mode"] = "bot_api"
|
||
return out
|
||
|
||
|
||
def admin_static_dir() -> Path:
|
||
return Path(__file__).resolve().parent / "static"
|
||
|
||
|
||
def build_admin_router() -> APIRouter:
|
||
router = APIRouter()
|
||
|
||
static_dir = admin_static_dir()
|
||
|
||
def _sha256_hex(v: str) -> str:
|
||
return hashlib.sha256(str(v or "").encode("utf-8", errors="ignore")).hexdigest()
|
||
|
||
def _now_utc() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
def _parse_iso(v: str) -> datetime | None:
|
||
try:
|
||
return datetime.fromisoformat(str(v))
|
||
except Exception:
|
||
return None
|
||
|
||
def _extract_bearer(authorization: str | None) -> str:
|
||
blob = str(authorization or "").strip()
|
||
if not blob:
|
||
return ""
|
||
parts = blob.split(None, 1)
|
||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||
return parts[1].strip()
|
||
return ""
|
||
|
||
def _resolve_login_tenant_id(store: SqliteStore, *, username: str, tenant_id: str) -> str:
|
||
"""Pick tenant when login body omits tenant_id.
|
||
|
||
Chat login sends tenant_id=\"\". Using list_tenants(limit=1) alone picks the newest tenant
|
||
(often pg-smoke), which has no chat history. For administrator, prefer the Team tenant,
|
||
then the tenant with the most owned sessions.
|
||
"""
|
||
explicit = str(tenant_id or "").strip()
|
||
if explicit:
|
||
return explicit
|
||
uname = str(username or "").strip().lower()
|
||
tenants = store.list_tenants(limit=500)
|
||
if not tenants:
|
||
return ""
|
||
if uname == "administrator":
|
||
for row in tenants:
|
||
tid = str((row or {}).get("id") or "").strip()
|
||
if not tid:
|
||
continue
|
||
if str((row or {}).get("name") or "").strip().lower() != "team":
|
||
continue
|
||
if store.get_user_by_username(tenant_id=tid, username="administrator"):
|
||
return tid
|
||
best_tid = ""
|
||
best_n = -1
|
||
for row in tenants:
|
||
tid = str((row or {}).get("id") or "").strip()
|
||
if not tid:
|
||
continue
|
||
user = store.get_user_by_username(tenant_id=tid, username="administrator")
|
||
if not user:
|
||
continue
|
||
uid = str(user.get("id") or "")
|
||
meta = store.get_sessions_list_meta_for_user(tenant_id=tid, user_id=uid)
|
||
n = int(meta.session_count or 0)
|
||
if n > best_n:
|
||
best_n = n
|
||
best_tid = tid
|
||
if best_tid:
|
||
return best_tid
|
||
return str((tenants[0] or {}).get("id") or "").strip()
|
||
|
||
def _resolve_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]:
|
||
token = _extract_bearer(authorization)
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="missing_bearer_token")
|
||
session = store.get_auth_session(session_token_hash=_sha256_hex(token))
|
||
if not session:
|
||
raise HTTPException(status_code=401, detail="invalid_session")
|
||
if session.get("revoked_at"):
|
||
raise HTTPException(status_code=401, detail="session_revoked")
|
||
expires_at = _parse_iso(str(session.get("expires_at") or ""))
|
||
if expires_at is None or expires_at <= _now_utc():
|
||
raise HTTPException(status_code=401, detail="session_expired")
|
||
user = store.get_user_by_id(
|
||
tenant_id=str(session.get("tenant_id") or ""),
|
||
user_id=str(session.get("user_id") or ""),
|
||
)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="user_not_found")
|
||
if not bool(user.get("is_active")):
|
||
raise HTTPException(status_code=403, detail="user_disabled")
|
||
store.touch_auth_session(session_token_hash=str(session.get("session_token_hash") or ""))
|
||
perms = store.list_user_permissions(
|
||
tenant_id=str(user.get("tenant_id") or ""),
|
||
user_id=str(user.get("id") or ""),
|
||
role=str(user.get("role") or "member"),
|
||
)
|
||
return {
|
||
"tenant_id": user["tenant_id"],
|
||
"user_id": user["id"],
|
||
"username": str(user.get("username") or ""),
|
||
"role": user["role"],
|
||
"permissions": perms,
|
||
}
|
||
|
||
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
|
||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||
if permission in perms:
|
||
return
|
||
if str(ctx.get("role") or "") == "owner":
|
||
return
|
||
raise HTTPException(status_code=403, detail=f"forbidden:{permission}")
|
||
|
||
def _require_tenant_scope(ctx: dict[str, Any], tenant_id: str) -> None:
|
||
if str(ctx.get("tenant_id") or "") == str(tenant_id or ""):
|
||
return
|
||
# 控制台仅 administrator 可登录;该账号允许跨租户读写(与单租户会话 tenant_id 解耦)。
|
||
if str(ctx.get("username") or "").strip().lower() == "administrator":
|
||
return
|
||
raise HTTPException(status_code=403, detail="cross_tenant_forbidden")
|
||
|
||
def _resolve_ops_ai_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]:
|
||
shared = str(os.getenv("OCLAW_OPS_AI_SHARED_TOKEN") or "").strip()
|
||
token = _extract_bearer(authorization)
|
||
if shared and token and hmac.compare_digest(shared, token):
|
||
return {
|
||
"tenant_id": "",
|
||
"user_id": "ops-ai-service",
|
||
"username": "ops-ai-service",
|
||
"role": "service",
|
||
"permissions": ["admin:read"],
|
||
}
|
||
return _resolve_auth(store, authorization)
|
||
|
||
def _ordered_specialists() -> list[str]:
|
||
base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()]
|
||
preferred = [x for x in ("generalist", "ops", "memory") if x in set(base)]
|
||
return preferred + [x for x in base if x not in set(preferred)]
|
||
|
||
def _ordered_mcp_roles() -> list[str]:
|
||
return _ordered_specialists()
|
||
|
||
def _ordered_roles() -> list[str]:
|
||
"""Canonical role list used by Admin preview APIs."""
|
||
return _ordered_specialists()
|
||
|
||
_EXPERT_ALLOWED_FILES = {"SOUL.md", "ROLE_SYSTEM.md"}
|
||
|
||
def _serialize_expert_row(row: dict[str, Any]) -> dict[str, Any]:
|
||
files = row.get("files") if isinstance(row, dict) else {}
|
||
obj = files if isinstance(files, dict) else {}
|
||
return {
|
||
"id": str(row.get("id") or ""),
|
||
"builtin": bool(row.get("builtin")),
|
||
"has_required_soul": bool(row.get("has_required_soul")),
|
||
"display_name_en": str(row.get("display_name_en") or ""),
|
||
"display_name_zh": str(row.get("display_name_zh") or ""),
|
||
"role": str(row.get("role") or "expert"),
|
||
"path": str(row.get("path") or ""),
|
||
"files": {
|
||
"SOUL.md": str(obj.get("SOUL.md") or ""),
|
||
"ROLE_SYSTEM.md": str(obj.get("ROLE_SYSTEM.md") or ""),
|
||
},
|
||
}
|
||
|
||
def _sanitize_expert_files_payload(raw: Any) -> dict[str, str]:
|
||
files_raw = raw if isinstance(raw, dict) else {}
|
||
files = {str(k): str(v or "") for k, v in files_raw.items()}
|
||
bad = [k for k in files.keys() if k not in _EXPERT_ALLOWED_FILES]
|
||
if bad:
|
||
raise ValueError("unsupported_file_name")
|
||
return files
|
||
|
||
@router.post("/admin/api/tools/internal/reload")
|
||
def api_internal_tools_reload(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
|
||
from runtime.tools.expert_registry import clear_expert_tool_cache
|
||
from runtime.tools.public_registry import clear_public_tool_cache
|
||
|
||
clear_public_tool_cache()
|
||
clear_expert_tool_cache()
|
||
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="internal_tools_reload",
|
||
target_type="tool_cache",
|
||
target_id="public+expert",
|
||
status="ok",
|
||
detail={"cleared": True},
|
||
)
|
||
return {"ok": True, "cleared": True}
|
||
|
||
@router.get("/admin/api/tools/exposure-trace-setting")
|
||
def api_tools_exposure_trace_setting_get(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
raw = str(store.get_setting("AIA_TRACE_TOOL_EXPOSURE_PLAN") or "").strip().lower()
|
||
enabled = raw in {"1", "true", "yes", "on"}
|
||
return {"ok": True, "enabled": bool(enabled)}
|
||
|
||
@router.post("/admin/api/tools/exposure-trace-setting")
|
||
def api_tools_exposure_trace_setting_save(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
payload = payload or {}
|
||
enabled = bool(payload.get("enabled"))
|
||
store.set_setting("AIA_TRACE_TOOL_EXPOSURE_PLAN", "1" if enabled else "0")
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="tools_exposure_trace_setting_update",
|
||
target_type="app_setting",
|
||
target_id="AIA_TRACE_TOOL_EXPOSURE_PLAN",
|
||
status="ok",
|
||
detail={"enabled": bool(enabled)},
|
||
)
|
||
return {"ok": True, "enabled": bool(enabled)}
|
||
|
||
@router.get("/admin/api/tools/internal/preview")
|
||
def api_internal_tools_preview(
|
||
role: str | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
from runtime.tools.exposure_plan import build_internal_tool_specs
|
||
|
||
r = str(role or "").strip().lower()
|
||
available_roles = _ordered_roles()
|
||
if not r:
|
||
r = "generalist" if "generalist" in set(available_roles) else (available_roles[0] if available_roles else "")
|
||
if r not in set(available_roles):
|
||
raise HTTPException(status_code=400, detail="invalid_role")
|
||
|
||
specs, diag = build_internal_tool_specs(role=r, preview=True)
|
||
|
||
def _to_row(spec: Any, source: str) -> dict[str, Any]:
|
||
return {
|
||
"name": str(getattr(spec, "name", "") or ""),
|
||
"description": str(getattr(spec, "description", "") or ""),
|
||
"tags": sorted([str(x) for x in (getattr(spec, "tags", None) or [])]),
|
||
"read_only": bool(getattr(spec, "read_only", False)),
|
||
"risk_level": str(getattr(spec, "risk_level", "") or ""),
|
||
"timeout_s": float(getattr(spec, "timeout_s", 0.0) or 0.0),
|
||
}
|
||
|
||
tools = []
|
||
source_by_name = dict(diag.get("source_by_name") or {})
|
||
for s in specs:
|
||
src = str(source_by_name.get(str(getattr(s, "name", "") or ""), "expert"))
|
||
tools.append({**_to_row(s, src), "source": src})
|
||
tools.sort(key=lambda x: (str(x.get("source") or ""), str(x.get("name") or "")))
|
||
|
||
return {"ok": True, "role": r, "available_roles": available_roles, "tools": tools, **diag}
|
||
|
||
@router.get("/admin/api/tools/self-check")
|
||
def api_tools_self_check(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
"""One-shot diagnostic summary for role-based tool exposure."""
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
|
||
from runtime.tools.exposure_plan import build_internal_tool_specs, build_llm_tools_plan
|
||
|
||
roles = _ordered_roles()
|
||
items: list[dict[str, Any]] = []
|
||
total_wired = 0
|
||
total_internal = 0
|
||
for role in roles:
|
||
internal_specs, internal_diag = build_internal_tool_specs(role=role, preview=True)
|
||
llm_plan = build_llm_tools_plan(
|
||
store=store,
|
||
role=role,
|
||
base_url=None,
|
||
max_json_bytes=None,
|
||
include_mcp=True,
|
||
preview_internal=True,
|
||
)
|
||
total_wired += len(llm_plan.tools_wired)
|
||
total_internal += len(internal_specs)
|
||
items.append(
|
||
{
|
||
"role": role,
|
||
"role_mode": "unrestricted",
|
||
"internal_count": len(internal_specs),
|
||
"internal_public_count": int(internal_diag.get("public_count") or 0),
|
||
"internal_expert_count": int(internal_diag.get("expert_count") or 0),
|
||
"wired_count": len(llm_plan.tools_wired),
|
||
"raw_count": len(llm_plan.tools_raw),
|
||
"removed_total": len(llm_plan.removed_names),
|
||
"removed_mcp_total": len(llm_plan.removed_mcp_names),
|
||
"changed_total": len(llm_plan.changed_names),
|
||
"mcp_enabled": bool(llm_plan.mcp_enabled),
|
||
}
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"roles_total": len(roles),
|
||
"roles": roles,
|
||
"summary": {
|
||
"total_internal_tools": total_internal,
|
||
"total_wired_tools": total_wired,
|
||
},
|
||
"items": items,
|
||
}
|
||
|
||
@router.get("/admin/api/tools/llm/preview")
|
||
def api_llm_tools_preview(
|
||
role: str | None = Query(default=None),
|
||
base_url: str | None = Query(default=None),
|
||
max_json_bytes: int | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
"""Preview the final tools injected to LLM for a role (internal + MCP binding)."""
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
from runtime.tools.exposure_plan import build_llm_tools_plan
|
||
|
||
r = str(role or "").strip().lower()
|
||
available_roles = _ordered_roles()
|
||
if not r:
|
||
r = "generalist" if "generalist" in set(available_roles) else (available_roles[0] if available_roles else "")
|
||
if r not in set(available_roles):
|
||
raise HTTPException(status_code=400, detail="invalid_role")
|
||
plan = build_llm_tools_plan(
|
||
store=store,
|
||
role=r,
|
||
base_url=base_url,
|
||
max_json_bytes=max_json_bytes,
|
||
include_mcp=True,
|
||
preview_internal=True,
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"role": plan.role,
|
||
"available_roles": available_roles,
|
||
"base_url": plan.base_url,
|
||
"max_json_bytes": plan.max_json_bytes,
|
||
"mcp_enabled": plan.mcp_enabled,
|
||
"role_mode": plan.role_mode,
|
||
"raw_count": len(plan.tools_raw),
|
||
"wired_count": len(plan.tools_wired),
|
||
"removed_mcp_names": plan.removed_mcp_names,
|
||
"tools_raw": plan.tools_raw,
|
||
"tools_wired": plan.tools_wired,
|
||
"public_risk_gate_allow_high": plan.public_risk_gate_allow_high,
|
||
"public_blocked_high_risk_tools": plan.public_blocked_high_risk_tools,
|
||
"skipped_public": plan.skipped_public,
|
||
"skipped_expert": plan.skipped_expert,
|
||
}
|
||
|
||
def _load_mcp_specialist_binding(store: SqliteStore) -> dict[str, list[str]]:
|
||
raw = str(store.get_setting("mcp_specialist_server_binding") or "").strip()
|
||
if not raw:
|
||
return {}
|
||
try:
|
||
obj = json.loads(raw)
|
||
except Exception:
|
||
return {}
|
||
if not isinstance(obj, dict):
|
||
return {}
|
||
out: dict[str, list[str]] = {}
|
||
for k, v in obj.items():
|
||
sid = str(k or "").strip().lower()
|
||
if not sid:
|
||
continue
|
||
items = v if isinstance(v, list) else []
|
||
vals = [str(x).strip() for x in items if str(x).strip()]
|
||
out[sid] = vals
|
||
return out
|
||
|
||
def _normalize_mcp_specialist_binding(
|
||
*,
|
||
available_specialists: list[str],
|
||
servers: list[dict[str, Any]],
|
||
mapping: dict[str, Any],
|
||
) -> dict[str, list[str]]:
|
||
available_set = set(available_specialists)
|
||
server_ids = {str(x.get("server_id") or "").strip() for x in servers if str(x.get("server_id") or "").strip()}
|
||
out: dict[str, list[str]] = {}
|
||
for sp in available_specialists:
|
||
rows = mapping.get(sp) if isinstance(mapping, dict) else None
|
||
items = rows if isinstance(rows, list) else []
|
||
vals = []
|
||
seen: set[str] = set()
|
||
for x in items:
|
||
sid = str(x or "").strip()
|
||
if not sid or sid not in server_ids or sid in seen:
|
||
continue
|
||
seen.add(sid)
|
||
vals.append(sid)
|
||
out[sp] = vals
|
||
# drop any unknown specialist keys by only returning available list keys
|
||
for key in list(out.keys()):
|
||
if key not in available_set:
|
||
out.pop(key, None)
|
||
return out
|
||
|
||
def _ensure_admin_bootstrap(store: SqliteStore) -> None:
|
||
tenants = store.list_tenants(limit=500)
|
||
if not tenants:
|
||
store.create_tenant("Team")
|
||
tenants = store.list_tenants(limit=500)
|
||
pwd = load_expected_password(store)
|
||
if not pwd:
|
||
# Fail closed: require explicit password configuration.
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="missing_admin_password: set AIA_ASSISTANT_PASSWORD env or store secret auth_password first",
|
||
)
|
||
pwd_hash = _sha256_hex(pwd)
|
||
for tenant in tenants:
|
||
tid = str((tenant or {}).get("id") or "").strip()
|
||
if not tid:
|
||
continue
|
||
if store.get_user_by_username(tenant_id=tid, username="administrator"):
|
||
continue
|
||
store.create_user_account(
|
||
tenant_id=tid,
|
||
username="administrator",
|
||
display_name="Administrator",
|
||
role="admin",
|
||
password_hash=pwd_hash,
|
||
is_active=True,
|
||
)
|
||
|
||
@router.get("/admin", response_class=HTMLResponse)
|
||
def admin_root() -> Any:
|
||
p = static_dir / "index.html"
|
||
return HTMLResponse(p.read_text(encoding="utf-8"))
|
||
|
||
@router.get("/admin/api/channels")
|
||
def api_channels(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
ctx = _resolve_auth(get_assistant_store(), authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
reg = build_channel_registry()
|
||
items = [{"name": k, "type": reg[k].__class__.__name__} for k in sorted(reg.keys())]
|
||
return {"ok": True, "channels": items}
|
||
|
||
@router.get("/admin/api/stack/status")
|
||
def api_stack_status(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
ctx = _resolve_auth(get_assistant_store(), authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
items = []
|
||
for s in status_services():
|
||
all_pids = list_service_process_pids(str(s.name))
|
||
pid_ports: dict[str, list[int]] = {}
|
||
pid_running: dict[str, bool] = {}
|
||
ports_set: set[int] = set()
|
||
for p in all_pids:
|
||
pp = list_listen_ports_for_pid(int(p))
|
||
pid_ports[str(int(p))] = [int(x) for x in pp]
|
||
pid_running[str(int(p))] = bool(is_pid_running(int(p)))
|
||
for x in pp:
|
||
ports_set.add(int(x))
|
||
running_any = any(bool(v) for v in pid_running.values()) if pid_running else bool(s.running)
|
||
# Choose primary pid from the current snapshot (do not "stick" to state pid).
|
||
live = sorted([int(k) for k, v in pid_running.items() if v])
|
||
primary_pid = 0
|
||
if live:
|
||
name = str(getattr(s, "name", "") or "")
|
||
# Prefer gateway PID that actually binds the gateway port.
|
||
if name == "gateway":
|
||
prefer_port = 8787
|
||
candidates = [p for p in live if int(prefer_port) in set(pid_ports.get(str(p), []) or [])]
|
||
if candidates:
|
||
primary_pid = int(candidates[0])
|
||
if not primary_pid:
|
||
# Prefer PID(s) with any listening ports.
|
||
with_ports = [p for p in live if (pid_ports.get(str(p), []) or [])]
|
||
if with_ports:
|
||
# Choose the one with most ports, tie-breaker: smallest pid
|
||
with_ports.sort(key=lambda p: (-len(pid_ports.get(str(p), []) or []), int(p)))
|
||
primary_pid = int(with_ports[0])
|
||
if not primary_pid:
|
||
# Fall back to smallest live pid for stability.
|
||
primary_pid = int(live[0])
|
||
items.append(
|
||
{
|
||
"name": s.name,
|
||
"pid": int(primary_pid or 0),
|
||
"running": bool(running_any),
|
||
"all_pids": sorted(int(x) for x in all_pids if int(x) > 0),
|
||
"ports": sorted(ports_set),
|
||
"pid_ports": pid_ports,
|
||
"pid_running": pid_running,
|
||
"duplicate_count": max(0, len(set(int(x) for x in all_pids if int(x) > 0)) - 1),
|
||
}
|
||
)
|
||
return {"ok": True, "items": items}
|
||
|
||
@router.post("/admin/api/stack/up")
|
||
def api_stack_up(channel: str = "wecom", authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
ctx = _resolve_auth(get_assistant_store(), authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
# Use same defaults as CLI; this starts detached processes and writes runtime state.
|
||
import argparse
|
||
|
||
ns = argparse.Namespace(
|
||
channel=channel,
|
||
channel_mode="ws",
|
||
channel_interval=3.0,
|
||
deliver_outbound=True,
|
||
pull_url=None,
|
||
gateway_host="0.0.0.0",
|
||
gateway_port=8787,
|
||
with_ui=False,
|
||
ui_port=8501,
|
||
)
|
||
cmd_stack_up(ns)
|
||
return api_stack_status()
|
||
|
||
@router.post("/admin/api/stack/down")
|
||
def api_stack_down(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
ctx = _resolve_auth(get_assistant_store(), authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
import argparse
|
||
|
||
ns = argparse.Namespace()
|
||
cmd_stack_down(ns)
|
||
return api_stack_status()
|
||
|
||
def _collect_runtime_anomalies(store: SqliteStore) -> dict[str, Any]:
|
||
items = []
|
||
running = status_services()
|
||
for s in running:
|
||
if not bool(getattr(s, "running", False)):
|
||
# Duplicate worker checks only make sense when a primary service
|
||
# is currently running. Otherwise this creates noisy conflicts
|
||
# with "missing service" alerts.
|
||
continue
|
||
keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set()
|
||
all_pids = list_service_process_pids(s.name)
|
||
if (not keep) and len(all_pids) > 1:
|
||
# Ambiguous primary process: do not guess and do not auto-clean.
|
||
# Require explicit stack restart to re-elect primary PID safely.
|
||
items.append(
|
||
{
|
||
"type": "duplicate_process_ambiguous",
|
||
"service": s.name,
|
||
"expected_pid": 0,
|
||
"orphan_pids": sorted(int(x) for x in all_pids),
|
||
"severity": "critical",
|
||
"message": (
|
||
f"{s.name} has multiple workers but no stable primary pid in runtime state: "
|
||
f"{', '.join(str(x) for x in sorted(all_pids))}. "
|
||
"Please restart stack (down/up)."
|
||
),
|
||
}
|
||
)
|
||
continue
|
||
orphans = detect_orphan_service_processes(s.name, keep_pids=keep)
|
||
if orphans:
|
||
items.append(
|
||
{
|
||
"type": "duplicate_process",
|
||
"service": s.name,
|
||
"expected_pid": int(s.pid or 0),
|
||
"orphan_pids": sorted(int(x) for x in orphans),
|
||
"severity": "critical",
|
||
"message": f"{s.name} has duplicate workers: {', '.join(str(x) for x in sorted(orphans))}",
|
||
}
|
||
)
|
||
last_parse_error = str(store.get_setting("wecom_last_parse_error") or "").strip()
|
||
last_outbound_error = str(store.get_setting("wecom_last_outbound_error") or "").strip()
|
||
if last_parse_error:
|
||
sev = "warning"
|
||
if "disconnected_event" in last_parse_error.lower():
|
||
sev = "critical"
|
||
items.append(
|
||
{
|
||
"type": "wecom_parse_error",
|
||
"service": "channel:wecom",
|
||
"severity": sev,
|
||
"message": f"wecom_last_parse_error={last_parse_error}",
|
||
}
|
||
)
|
||
if last_outbound_error:
|
||
items.append(
|
||
{
|
||
"type": "wecom_outbound_error",
|
||
"service": "channel:wecom",
|
||
"severity": "critical",
|
||
"message": f"wecom_last_outbound_error={last_outbound_error}",
|
||
}
|
||
)
|
||
must_cleanup = any(str(x.get("type") or "") == "duplicate_process" for x in items)
|
||
return {"ok": True, "must_cleanup": must_cleanup, "items": items}
|
||
|
||
@router.get("/admin/api/runtime/anomalies")
|
||
def api_runtime_anomalies(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
return _collect_runtime_anomalies(store)
|
||
|
||
@router.post("/admin/api/runtime/cleanup")
|
||
def api_runtime_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
killed: list[dict[str, Any]] = []
|
||
# Cleanup rule:
|
||
# - show all PIDs to user; cleanup should be conservative.
|
||
# - for a service with multiple workers, kill those with NO listening ports.
|
||
# - never delete the last remaining worker for a service.
|
||
for s in status_services():
|
||
service = str(getattr(s, "name", "") or "").strip()
|
||
if not service:
|
||
continue
|
||
all_pids = [int(x) for x in list_service_process_pids(service) if int(x) > 0]
|
||
if len(all_pids) <= 1:
|
||
continue
|
||
pid_ports: dict[int, list[int]] = {int(p): list_listen_ports_for_pid(int(p)) for p in all_pids}
|
||
with_ports = [p for p in all_pids if pid_ports.get(int(p))]
|
||
without_ports = [p for p in all_pids if not pid_ports.get(int(p))]
|
||
victims: list[int] = []
|
||
if with_ports:
|
||
victims = list(without_ports)
|
||
else:
|
||
# If none are listening (e.g. long-polling channel workers),
|
||
# keep the recorded primary pid when possible, otherwise keep the first.
|
||
primary = int(getattr(s, "pid", 0) or 0)
|
||
keep = primary if primary in all_pids else int(all_pids[0])
|
||
victims = [p for p in all_pids if int(p) != int(keep)]
|
||
if victims and len(victims) >= len(all_pids):
|
||
victims = victims[: max(0, len(all_pids) - 1)]
|
||
dead = cleanup_service_processes_by_pid(service, victims)
|
||
if dead:
|
||
killed.append({"service": service, "killed_pids": sorted(int(x) for x in dead)})
|
||
anomalies = _collect_runtime_anomalies(store)
|
||
return {"ok": True, "killed": killed, "anomalies": anomalies}
|
||
|
||
@router.get("/admin/api/runtime/prewarm/status")
|
||
def api_runtime_prewarm_status(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
return runtime_prewarm_status(store=store)
|
||
|
||
@router.post("/admin/api/runtime/prewarm")
|
||
def api_runtime_prewarm(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
body = payload or {}
|
||
mode = str(body.get("mode") or "async").strip().lower()
|
||
reason = str(body.get("reason") or "admin_manual").strip() or "admin_manual"
|
||
if mode == "sync":
|
||
return run_runtime_prewarm(reason=reason, store=store)
|
||
import threading
|
||
|
||
def _run() -> None:
|
||
try:
|
||
_ = run_runtime_prewarm(reason=reason, store=get_assistant_store())
|
||
except Exception:
|
||
pass
|
||
|
||
th = threading.Thread(target=_run, name="oclaw-admin-prewarm", daemon=True)
|
||
th.start()
|
||
return {"ok": True, "accepted": True, "mode": "async", "status": runtime_prewarm_status(store=store)}
|
||
|
||
@router.get("/admin/api/runtime/prewarm/prompts")
|
||
def api_runtime_prewarm_prompts(
|
||
role: str | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
return runtime_prewarm_prompts_snapshot(store=store, role=str(role or "").strip().lower() or None)
|
||
|
||
@router.get("/admin/api/runtime/scan-artifacts")
|
||
def api_runtime_scan_artifacts(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
ctx = _resolve_auth(get_assistant_store(), authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve()
|
||
allowed_prefixes = ("history_entries_", "state_scan_")
|
||
items: list[dict[str, Any]] = []
|
||
if root.exists() and root.is_dir():
|
||
for p in sorted(root.glob("*.json"), key=lambda x: x.name.lower()):
|
||
if not p.name.startswith(allowed_prefixes):
|
||
continue
|
||
try:
|
||
st = p.stat()
|
||
items.append(
|
||
{
|
||
"name": p.name,
|
||
"path": str(p),
|
||
"bytes": int(st.st_size),
|
||
"modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
||
}
|
||
)
|
||
except Exception:
|
||
continue
|
||
return {"ok": True, "dir": str(root), "count": len(items), "items": items}
|
||
|
||
@router.post("/admin/api/runtime/scan-artifacts/cleanup")
|
||
def api_runtime_scan_artifacts_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
ctx = _resolve_auth(get_assistant_store(), authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve()
|
||
allowed_prefixes = ("history_entries_", "state_scan_")
|
||
removed = 0
|
||
if root.exists() and root.is_dir():
|
||
for p in root.glob("*.json"):
|
||
if not p.name.startswith(allowed_prefixes):
|
||
continue
|
||
try:
|
||
p.unlink(missing_ok=True)
|
||
removed += 1
|
||
except Exception:
|
||
continue
|
||
return {"ok": True, "dir": str(root), "removed": int(removed)}
|
||
|
||
@router.post("/admin/api/runtime/scan-artifacts/prune")
|
||
def api_runtime_scan_artifacts_prune(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
body = payload or {}
|
||
ctx = _resolve_auth(get_assistant_store(), authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve()
|
||
try:
|
||
keep_latest = int(body.get("keep_latest", 20))
|
||
except Exception:
|
||
keep_latest = 20
|
||
try:
|
||
max_age_days = int(body.get("max_age_days", 7))
|
||
except Exception:
|
||
max_age_days = 7
|
||
keep_latest = max(0, min(keep_latest, 500))
|
||
max_age_days = max(0, min(max_age_days, 3650))
|
||
allowed_prefixes = ("history_entries_", "state_scan_")
|
||
rows: list[tuple[Path, float]] = []
|
||
if root.exists() and root.is_dir():
|
||
for p in root.glob("*.json"):
|
||
if not p.name.startswith(allowed_prefixes):
|
||
continue
|
||
try:
|
||
rows.append((p, float(p.stat().st_mtime)))
|
||
except Exception:
|
||
continue
|
||
rows.sort(key=lambda x: x[1], reverse=True)
|
||
cutoff_ts = None
|
||
if max_age_days > 0:
|
||
cutoff_ts = (_now_utc() - timedelta(days=max_age_days)).timestamp()
|
||
removed = 0
|
||
for idx, (p, mtime) in enumerate(rows):
|
||
remove_by_rank = keep_latest >= 0 and idx >= keep_latest
|
||
remove_by_age = cutoff_ts is not None and mtime < cutoff_ts
|
||
if not remove_by_rank and not remove_by_age:
|
||
continue
|
||
try:
|
||
p.unlink(missing_ok=True)
|
||
removed += 1
|
||
except Exception:
|
||
continue
|
||
return {
|
||
"ok": True,
|
||
"dir": str(root),
|
||
"removed": int(removed),
|
||
"keep_latest": int(keep_latest),
|
||
"max_age_days": int(max_age_days),
|
||
}
|
||
|
||
@router.post("/admin/api/runtime/sqlite-retention/prune")
|
||
def api_runtime_sqlite_retention_prune(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
"""Dry-run or apply retention prune for tool/trace/outbound noise.
|
||
|
||
Body:
|
||
keep_days (default 30), dry_run (default true), vacuum (default false),
|
||
include_scheduled_runs (default false), include_outbound (default true).
|
||
Apply requires dry_run=false and confirm=true.
|
||
"""
|
||
from svc.persistence.sqlite_retention import prune_sqlite_retention
|
||
|
||
body = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
try:
|
||
keep_days = int(body.get("keep_days", 30))
|
||
except Exception:
|
||
keep_days = 30
|
||
dry_run = body.get("dry_run", True)
|
||
if isinstance(dry_run, str):
|
||
dry_run = dry_run.strip().lower() not in {"0", "false", "no", "off"}
|
||
else:
|
||
dry_run = bool(dry_run)
|
||
vacuum = bool(body.get("vacuum", False))
|
||
include_scheduled_runs = bool(body.get("include_scheduled_runs", False))
|
||
include_outbound = body.get("include_outbound", True)
|
||
if isinstance(include_outbound, str):
|
||
include_outbound = include_outbound.strip().lower() not in {"0", "false", "no", "off"}
|
||
else:
|
||
include_outbound = bool(include_outbound)
|
||
if not dry_run and not bool(body.get("confirm", False)):
|
||
return {
|
||
"ok": False,
|
||
"error": "confirm_required",
|
||
"hint": "Set dry_run=false and confirm=true to delete. Prefer dry_run first.",
|
||
}
|
||
return prune_sqlite_retention(
|
||
store,
|
||
keep_days=keep_days,
|
||
dry_run=dry_run,
|
||
vacuum=vacuum and not dry_run,
|
||
include_outbound_sent=include_outbound,
|
||
include_scheduled_runs=include_scheduled_runs,
|
||
)
|
||
|
||
@router.get("/admin/api/tenants")
|
||
def api_tenants(
|
||
scope: str | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:read")
|
||
rows = store.list_tenants(limit=500)
|
||
if str(scope or "").strip().lower() == "mine":
|
||
tid = str(ctx.get("tenant_id") or "").strip()
|
||
rows = [r for r in rows if str(r.get("id") or "") == tid]
|
||
return {"ok": True, "tenants": rows}
|
||
|
||
@router.post("/admin/api/tenants/create")
|
||
def api_tenants_create(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
name = str(payload.get("name") or "").strip() or "Team"
|
||
tenant = store.create_tenant(name)
|
||
return {"ok": True, "tenant": tenant}
|
||
|
||
@router.post("/admin/api/tenants/delete")
|
||
def api_tenants_delete(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
if not tenant_id:
|
||
return {"ok": False, "error": "tenant_id is required"}
|
||
all_tenants = store.list_tenants(limit=1000)
|
||
if len(all_tenants) <= 1:
|
||
return {"ok": False, "error": "last_tenant_cannot_delete"}
|
||
if tenant_id == str(ctx.get("tenant_id") or "").strip():
|
||
return {"ok": False, "error": "cannot_delete_current_tenant"}
|
||
if not any(str(r.get("id") or "") == tenant_id for r in all_tenants):
|
||
return {"ok": False, "error": "tenant_not_found"}
|
||
deleted = store.delete_tenant(tenant_id=tenant_id)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="tenant_delete",
|
||
target_type="tenant",
|
||
target_id=tenant_id,
|
||
status="ok" if deleted > 0 else "miss",
|
||
)
|
||
return {"ok": True, "deleted": deleted}
|
||
|
||
@router.get("/admin/api/bindings")
|
||
def api_bindings(
|
||
tenant_id: str,
|
||
channel: str = "wecom",
|
||
user_id: str | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:read")
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
uid = str(user_id or "").strip() or None
|
||
# Prefer v2 bindings for WeCom (includes account_id + account_name).
|
||
if str(channel or "").strip().lower() == "wecom":
|
||
rows = store.list_channel_identities_v2(
|
||
tenant_id=tenant_id, channel=channel, user_id=uid, limit=2000
|
||
)
|
||
else:
|
||
rows = store.list_channel_identities(tenant_id=tenant_id, channel=channel, limit=1000)
|
||
if uid:
|
||
rows = [r for r in rows if str(r.get("user_id") or "") == uid]
|
||
return {"ok": True, "bindings": rows, "version": 2 if str(channel or "").strip().lower() == "wecom" else 1}
|
||
|
||
@router.get("/admin/api/user-channel-accounts")
|
||
def api_user_channel_accounts(
|
||
tenant_id: str,
|
||
user_id: str,
|
||
channel: str = "wecom",
|
||
include_inactive: int = Query(default=1),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:read")
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
rows = store.list_user_channel_accounts(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
channel=channel,
|
||
include_inactive=bool(int(include_inactive or 0)),
|
||
)
|
||
if str(channel or "").strip().lower() == "wecom":
|
||
rows = [_enrich_wecom_channel_account(store, tenant_id, user_id, dict(x)) for x in rows]
|
||
return {"ok": True, "items": rows}
|
||
|
||
@router.post("/admin/api/user-channel-accounts/upsert")
|
||
def api_user_channel_accounts_upsert(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
user_id = str(payload.get("user_id") or "").strip()
|
||
channel = str(payload.get("channel") or "wecom").strip() or "wecom"
|
||
account_id = str(payload.get("account_id") or "").strip()
|
||
name = str(payload.get("name") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
if not tenant_id or not user_id or not account_id:
|
||
return {"ok": False, "error": "tenant_id, user_id, account_id are required"}
|
||
if not store.get_user_by_id(tenant_id=tenant_id, user_id=user_id):
|
||
return {"ok": False, "error": "user_not_found"}
|
||
is_active = payload.get("is_active")
|
||
cfg: dict[str, Any] = {}
|
||
existing_rows = store.list_user_channel_accounts(
|
||
tenant_id=tenant_id, user_id=user_id, channel=channel, include_inactive=True
|
||
)
|
||
cur_row = next((x for x in existing_rows if str(x.get("account_id") or "") == account_id), None)
|
||
if cur_row and isinstance(cur_row.get("config"), dict):
|
||
cfg = dict(cur_row["config"])
|
||
if isinstance(payload.get("config"), dict):
|
||
cfg.update(payload["config"])
|
||
if str(channel or "").strip().lower() == "wecom":
|
||
cfg["wecom_mode"] = "bot_api"
|
||
cfg.pop("wecom_corp_id", None)
|
||
cfg.pop("wecom_agent_id", None)
|
||
bot_secret = str(payload.get("wecom_bot_secret") or payload.get("bot_secret") or "").strip()
|
||
if bot_secret:
|
||
store.set_secret(_wecom_bot_secret_key(tenant_id, user_id, account_id), bot_secret)
|
||
if payload.get("clear_bot_secret") is True:
|
||
store.delete_setting(_wecom_bot_secret_key(tenant_id, user_id, account_id))
|
||
store.upsert_user_channel_account(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
channel=channel,
|
||
account_id=account_id,
|
||
name=name,
|
||
config=cfg,
|
||
is_active=bool(is_active) if isinstance(is_active, bool) else True,
|
||
)
|
||
rows = store.list_user_channel_accounts(tenant_id=tenant_id, user_id=user_id, channel=channel, include_inactive=True)
|
||
row = next((x for x in rows if str(x.get("account_id") or "") == account_id), None)
|
||
if str(channel or "").strip().lower() == "wecom" and row:
|
||
row = _enrich_wecom_channel_account(store, tenant_id, user_id, dict(row))
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="user_channel_account_upsert",
|
||
target_type="user_channel_account",
|
||
target_id=f"{tenant_id}:{user_id}:{channel}:{account_id}",
|
||
status="ok",
|
||
detail={"name": name, "is_active": bool((row or {}).get("is_active", True))},
|
||
)
|
||
return {"ok": True, "item": row or {}}
|
||
|
||
@router.post("/admin/api/user-channel-accounts/delete")
|
||
def api_user_channel_accounts_delete(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
user_id = str(payload.get("user_id") or "").strip()
|
||
channel = str(payload.get("channel") or "wecom").strip() or "wecom"
|
||
account_id = str(payload.get("account_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
if not tenant_id or not user_id or not account_id:
|
||
return {"ok": False, "error": "tenant_id, user_id, account_id are required"}
|
||
deleted = store.delete_user_channel_account(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
channel=channel,
|
||
account_id=account_id,
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="user_channel_account_delete",
|
||
target_type="user_channel_account",
|
||
target_id=f"{tenant_id}:{user_id}:{channel}:{account_id}",
|
||
status="ok" if deleted > 0 else "miss",
|
||
)
|
||
return {"ok": True, "deleted": deleted}
|
||
|
||
@router.get("/admin/api/whatsapp/groups")
|
||
def api_whatsapp_groups(
|
||
tenant_id: str = Query(default="default"),
|
||
account_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tid = str(tenant_id or "default").strip()
|
||
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid)
|
||
session_groups: list[dict[str, Any]] = []
|
||
try:
|
||
with store._connect() as conn:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT DISTINCT external_chat_id, session_id
|
||
FROM channel_session_v2
|
||
WHERE tenant_id = ? AND channel = 'whatsapp' AND account_id = ? AND external_chat_id LIKE '%@g.us'
|
||
""",
|
||
(tid, aid),
|
||
).fetchall()
|
||
for r in rows:
|
||
jid = str(r[0] or "").strip()
|
||
if jid:
|
||
session_groups.append({"group_jid": jid, "session_id": str(r[1] or "")})
|
||
except Exception:
|
||
session_groups = []
|
||
merged: dict[str, dict[str, Any]] = {}
|
||
for g in known:
|
||
jid = str(g.get("group_jid") or "").strip()
|
||
if jid:
|
||
merged[jid] = g
|
||
for sg in session_groups:
|
||
jid = str(sg.get("group_jid") or "").strip()
|
||
if jid and jid not in merged:
|
||
merged[jid] = {
|
||
"tenant_id": tid,
|
||
"account_id": aid,
|
||
"group_jid": jid,
|
||
"group_name": "",
|
||
"last_seen_at": "",
|
||
"session_id": sg.get("session_id"),
|
||
}
|
||
binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid)
|
||
return {"ok": True, "items": list(merged.values()), "binding": binding}
|
||
|
||
@router.get("/admin/api/whatsapp/alert-binding")
|
||
def api_whatsapp_alert_binding_get(
|
||
tenant_id: str = Query(default="default"),
|
||
account_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tid = str(tenant_id or "default").strip()
|
||
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid)
|
||
return {"ok": True, "binding": binding}
|
||
|
||
@router.post("/admin/api/whatsapp/alert-binding")
|
||
def api_whatsapp_alert_binding_upsert(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tid = str(payload.get("tenant_id") or "default").strip()
|
||
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
group_jid = str(payload.get("group_jid") or "").strip()
|
||
if not group_jid:
|
||
return {"ok": False, "error": "group_jid_required"}
|
||
binding = store.upsert_whatsapp_alert_binding(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
group_jid=group_jid,
|
||
group_name=str(payload.get("group_name") or "").strip(),
|
||
enabled=bool(payload.get("enabled", True)),
|
||
)
|
||
return {"ok": True, "binding": binding}
|
||
|
||
@router.post("/admin/api/whatsapp/alert-binding/test")
|
||
def api_whatsapp_alert_binding_test(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tid = str(payload.get("tenant_id") or "default").strip()
|
||
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid)
|
||
if not binding or not bool(binding.get("enabled")):
|
||
return {"ok": False, "error": "binding_missing"}
|
||
group_jid = str(binding.get("group_jid") or "").strip()
|
||
if not group_jid:
|
||
return {"ok": False, "error": "group_jid_missing"}
|
||
msg_id = store.enqueue_channel_outbound_message(
|
||
channel="whatsapp",
|
||
chat_id=group_jid,
|
||
text="[OClaw] WhatsApp 告警群绑定测试消息",
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
source="admin.test",
|
||
)
|
||
return {"ok": True, "outbound_id": msg_id}
|
||
|
||
@router.get("/admin/api/whatsapp/access")
|
||
def api_whatsapp_access_get(
|
||
tenant_id: str = Query(default="default"),
|
||
account_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
|
||
legacy_tid = str(tenant_id or "default").strip()
|
||
# Be defensive: older DBs/gateways may not have the new tables yet.
|
||
# Do not break the entire Runtime page; return empty defaults instead.
|
||
try:
|
||
cfg = store.get_whatsapp_access_config(tenant_id=tid, account_id=aid) or {}
|
||
if not cfg and legacy_tid and legacy_tid != tid:
|
||
cfg = store.get_whatsapp_access_config(tenant_id=legacy_tid, account_id=aid) or {}
|
||
except Exception:
|
||
cfg = {}
|
||
contacts_by_phone: dict[str, dict[str, Any]] = {}
|
||
try:
|
||
from runtime.extensions.whatsapp.access_control import contact_phone_key
|
||
|
||
priority = {"admin": 4, "blacklist": 3, "whitelist": 2}
|
||
|
||
def _contact_rank(row: dict[str, Any]) -> int:
|
||
lt = str(row.get("list_type") or "").strip().lower()
|
||
return int(priority.get(lt, 0))
|
||
|
||
for source_tid in (tid, legacy_tid):
|
||
if not source_tid:
|
||
continue
|
||
for row in store.list_whatsapp_contacts(tenant_id=source_tid, account_id=aid, limit=500):
|
||
phone_key = contact_phone_key(row)
|
||
if not phone_key:
|
||
continue
|
||
prev = contacts_by_phone.get(phone_key)
|
||
if not prev or _contact_rank(row) > _contact_rank(prev):
|
||
contacts_by_phone[phone_key] = row
|
||
contacts = list(contacts_by_phone.values())
|
||
contacts = [
|
||
row
|
||
for row in contacts
|
||
if str(row.get("list_type") or "").strip().lower() in {"admin", "whitelist", "blacklist"}
|
||
]
|
||
except Exception:
|
||
contacts = []
|
||
pending_by_id: dict[str, dict[str, Any]] = {}
|
||
try:
|
||
for source_tid in (tid, legacy_tid):
|
||
if not source_tid:
|
||
continue
|
||
try:
|
||
store.expire_stale_whatsapp_access_pending(
|
||
tenant_id=source_tid,
|
||
account_id=aid,
|
||
older_than_hours=168,
|
||
)
|
||
except Exception:
|
||
pass
|
||
for row in store.list_whatsapp_access_pending(
|
||
tenant_id=source_tid, account_id=aid, status="pending", limit=50
|
||
):
|
||
pid = str(row.get("id") or "").strip()
|
||
if pid:
|
||
pending_by_id[pid] = row
|
||
pending = list(pending_by_id.values())
|
||
except Exception:
|
||
pending = []
|
||
denied_by_id: dict[str, dict[str, Any]] = {}
|
||
try:
|
||
for source_tid in (tid, legacy_tid):
|
||
if not source_tid:
|
||
continue
|
||
for row in store.list_whatsapp_access_pending(
|
||
tenant_id=source_tid, account_id=aid, status="denied", limit=50
|
||
):
|
||
pid = str(row.get("id") or "").strip()
|
||
if pid:
|
||
denied_by_id[pid] = row
|
||
denied = list(denied_by_id.values())
|
||
except Exception:
|
||
denied = []
|
||
return {
|
||
"ok": True,
|
||
"config": {
|
||
"tenant_id": tid,
|
||
"account_id": aid,
|
||
"access_mode": str(cfg.get("access_mode") or "blacklist"),
|
||
"lang": str(cfg.get("lang") or "en"),
|
||
},
|
||
"contacts": contacts,
|
||
"pending": pending,
|
||
"denied": denied,
|
||
}
|
||
|
||
@router.post("/admin/api/whatsapp/access/config")
|
||
def api_whatsapp_access_config_upsert(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
|
||
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
|
||
access_mode = str(payload.get("access_mode") or "blacklist").strip().lower()
|
||
lang = str(payload.get("lang") or "en").strip().lower()
|
||
cfg = store.upsert_whatsapp_access_config(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
access_mode=access_mode,
|
||
lang=lang,
|
||
)
|
||
return {"ok": True, "config": cfg}
|
||
|
||
@router.post("/admin/api/whatsapp/access/contacts")
|
||
def api_whatsapp_access_contact_upsert(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.extensions.whatsapp.access_control import (
|
||
normalize_whatsapp_phone,
|
||
resolve_sender_phone,
|
||
)
|
||
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
|
||
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
|
||
target_raw = str(payload.get("phone") or payload.get("external_user_id") or "").strip()
|
||
if not target_raw:
|
||
return {"ok": False, "error": "phone_required"}
|
||
try:
|
||
phone_val = normalize_whatsapp_phone(target_raw)
|
||
except Exception:
|
||
return {"ok": False, "error": "invalid_phone"}
|
||
list_type = str(payload.get("list_type") or "").strip().lower()
|
||
if list_type not in {"admin", "whitelist", "blacklist"}:
|
||
return {"ok": False, "error": "invalid_list_type"}
|
||
contact = store.apply_whatsapp_contact_access(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
external_user_id=phone_val,
|
||
push_name=str(payload.get("push_name") or ""),
|
||
phone=phone_val,
|
||
list_type=list_type,
|
||
notes=str(payload.get("notes") or ""),
|
||
)
|
||
resolved_by = str(ctx.get("user_id") or "")
|
||
pending_status = "approved" if list_type in {"admin", "whitelist"} else "denied"
|
||
legacy_tid = str(payload.get("tenant_id") or "default").strip()
|
||
extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else []
|
||
store.resolve_whatsapp_pending_for_sender(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
external_user_id=phone_val,
|
||
resolved_by=resolved_by,
|
||
status=pending_status,
|
||
extra_tenant_ids=extra_tids,
|
||
)
|
||
return {"ok": True, "contact": contact}
|
||
|
||
@router.delete("/admin/api/whatsapp/access/contacts")
|
||
def api_whatsapp_access_contact_delete(
|
||
tenant_id: str = Query(default="default"),
|
||
account_id: str = Query(default=""),
|
||
phone: str = Query(default=""),
|
||
external_user_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.extensions.whatsapp.access_control import normalize_whatsapp_phone
|
||
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
|
||
key = str(phone or external_user_id or "").strip()
|
||
if not key:
|
||
return {"ok": False, "error": "phone_required"}
|
||
try:
|
||
phone_val = normalize_whatsapp_phone(key)
|
||
except Exception:
|
||
return {"ok": False, "error": "invalid_phone"}
|
||
legacy_tid = str(tenant_id or "default").strip()
|
||
extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else []
|
||
deleted_count = store.delete_whatsapp_contact_aliases(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
external_user_id=phone_val,
|
||
extra_tenant_ids=extra_tids,
|
||
)
|
||
return {"ok": True, "deleted": deleted_count > 0, "deleted_count": deleted_count}
|
||
|
||
@router.post("/admin/api/whatsapp/access/pending/resolve")
|
||
def api_whatsapp_access_pending_resolve(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.extensions.whatsapp.access_control import resolve_sender_phone
|
||
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
|
||
|
||
body = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
aid = str(body.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
tid = resolve_whatsapp_tenant_id(store, account_id=aid)
|
||
legacy_tid = str(body.get("tenant_id") or "default").strip()
|
||
extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else []
|
||
pending_id = str(body.get("pending_id") or "").strip()
|
||
action = str(body.get("action") or "").strip().lower()
|
||
if not pending_id:
|
||
return {"ok": False, "error": "pending_id_required"}
|
||
if action not in {"approve", "deny", "dismiss"}:
|
||
return {"ok": False, "error": "invalid_action"}
|
||
|
||
items: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for source_tid in (tid, *extra_tids):
|
||
for status in ("pending", "denied"):
|
||
for row in store.list_whatsapp_access_pending(
|
||
tenant_id=source_tid, account_id=aid, status=status, limit=200
|
||
):
|
||
pid = str(row.get("id") or "")
|
||
if pid and pid not in seen:
|
||
seen.add(pid)
|
||
items.append(row)
|
||
|
||
row = next((x for x in items if str(x.get("id") or "") == pending_id), None)
|
||
if not row:
|
||
return {"ok": False, "error": "pending_not_found"}
|
||
|
||
external_user_id = str(row.get("external_user_id") or "").strip()
|
||
push_name = str(row.get("push_name") or "").strip()
|
||
resolved_by = str(ctx.get("user_id") or "")
|
||
|
||
if action == "dismiss":
|
||
deleted = store.delete_whatsapp_access_pending(pending_id=pending_id)
|
||
return {"ok": deleted, "action": "dismiss", "deleted": deleted}
|
||
|
||
if action == "approve":
|
||
phone_val = str(row.get("phone") or "").strip() or resolve_sender_phone(external_user_id)
|
||
if not phone_val:
|
||
return {"ok": False, "error": "invalid_phone"}
|
||
store.apply_whatsapp_contact_access(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
external_user_id=phone_val,
|
||
push_name=push_name,
|
||
phone=phone_val,
|
||
list_type="whitelist",
|
||
)
|
||
changed = store.resolve_whatsapp_access_pending(
|
||
pending_id=pending_id,
|
||
status="approved",
|
||
resolved_by=resolved_by,
|
||
from_statuses=("pending", "denied"),
|
||
)
|
||
store.resolve_whatsapp_pending_for_sender(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
external_user_id=phone_val,
|
||
resolved_by=resolved_by,
|
||
status="approved",
|
||
extra_tenant_ids=extra_tids,
|
||
)
|
||
try:
|
||
from runtime.application.gateway.whatsapp_inbound_access import (
|
||
notify_whatsapp_access_decision,
|
||
)
|
||
|
||
cfg = store.get_whatsapp_access_config(tenant_id=tid, account_id=aid) or {}
|
||
notify_whatsapp_access_decision(
|
||
store,
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
lang=str(cfg.get("lang") or "en"),
|
||
external_user_id=external_user_id,
|
||
phone=phone_val,
|
||
pending_id=pending_id,
|
||
approved=True,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"ok": bool(changed),
|
||
"action": "approve",
|
||
"phone": phone_val,
|
||
}
|
||
|
||
phone_val = str(row.get("phone") or "").strip() or resolve_sender_phone(external_user_id)
|
||
if phone_val:
|
||
store.apply_whatsapp_contact_access(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
external_user_id=phone_val,
|
||
push_name=push_name,
|
||
phone=phone_val,
|
||
list_type="blacklist",
|
||
)
|
||
changed = store.resolve_whatsapp_access_pending(
|
||
pending_id=pending_id,
|
||
status="denied",
|
||
resolved_by=resolved_by,
|
||
from_statuses=("pending",),
|
||
)
|
||
store.resolve_whatsapp_pending_for_sender(
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
external_user_id=phone_val or external_user_id,
|
||
resolved_by=resolved_by,
|
||
status="denied",
|
||
extra_tenant_ids=extra_tids,
|
||
)
|
||
try:
|
||
from runtime.application.gateway.whatsapp_inbound_access import (
|
||
notify_whatsapp_access_decision,
|
||
)
|
||
|
||
cfg = store.get_whatsapp_access_config(tenant_id=tid, account_id=aid) or {}
|
||
notify_whatsapp_access_decision(
|
||
store,
|
||
tenant_id=tid,
|
||
account_id=aid,
|
||
lang=str(cfg.get("lang") or "en"),
|
||
external_user_id=external_user_id,
|
||
phone=phone_val or "",
|
||
pending_id=pending_id,
|
||
approved=False,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return {"ok": bool(changed), "action": "deny", "phone": phone_val or external_user_id}
|
||
|
||
@router.delete("/admin/api/whatsapp/access/pending")
|
||
def api_whatsapp_access_pending_delete(
|
||
pending_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
pid = str(pending_id or "").strip()
|
||
if not pid:
|
||
return {"ok": False, "error": "pending_id_required"}
|
||
deleted = store.delete_whatsapp_access_pending(
|
||
pending_id=pid,
|
||
statuses=("pending", "denied"),
|
||
)
|
||
return {"ok": deleted, "deleted": deleted}
|
||
|
||
@router.get("/admin/api/whatsapp/session")
|
||
def api_whatsapp_session_get(
|
||
channel_id: str = Query(default="whatsapp"),
|
||
account_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.operations.whatsapp_sidecar import session_status
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
return session_status(channel_id=str(channel_id or "whatsapp").strip() or "whatsapp", account_id=aid)
|
||
|
||
@router.post("/admin/api/whatsapp/session/start")
|
||
def api_whatsapp_session_start(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.operations.whatsapp_sidecar import start_sidecar
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
body = payload or {}
|
||
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||
base = str(body.get("gateway_base_url") or os.getenv("AIA_GATEWAY_BASE_URL") or "http://127.0.0.1:8787").strip()
|
||
return start_sidecar(cid, gateway_base_url=base)
|
||
|
||
@router.post("/admin/api/whatsapp/session/stop")
|
||
def api_whatsapp_session_stop(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.operations.whatsapp_sidecar import session_status, stop_sidecar
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
body = payload or {}
|
||
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||
force = str(body.get("force") or "1").strip().lower() not in ("0", "false", "no", "off")
|
||
result = stop_sidecar(cid, force=force)
|
||
result["status"] = session_status(channel_id=cid)
|
||
return result
|
||
|
||
@router.post("/admin/api/whatsapp/session/unbind")
|
||
def api_whatsapp_session_unbind(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.operations.whatsapp_sidecar import unbind_session
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
body = payload or {}
|
||
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||
base = str(body.get("gateway_base_url") or os.getenv("AIA_GATEWAY_BASE_URL") or "http://127.0.0.1:8787").strip()
|
||
return unbind_session(cid, gateway_base_url=base)
|
||
|
||
@router.post("/admin/api/whatsapp/session/bind")
|
||
def api_whatsapp_session_bind(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.operations.whatsapp_sidecar import start_bind
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
body = payload or {}
|
||
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||
base = str(body.get("gateway_base_url") or os.getenv("AIA_GATEWAY_BASE_URL") or "http://127.0.0.1:8787").strip()
|
||
clear_auth = str(body.get("clear_auth") or "").strip().lower() in ("1", "true", "yes", "on")
|
||
return start_bind(cid, gateway_base_url=base, clear_auth=clear_auth)
|
||
|
||
@router.get("/admin/api/users")
|
||
def api_users(
|
||
tenant_id: str,
|
||
q: str | None = Query(default=None),
|
||
include_inactive: int = Query(default=1),
|
||
offset: int = Query(default=0),
|
||
limit: int = Query(default=1000),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:read")
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
rows = store.list_users(
|
||
tenant_id=tenant_id,
|
||
q=q,
|
||
include_inactive=bool(int(include_inactive or 0)),
|
||
offset=offset,
|
||
limit=limit,
|
||
)
|
||
return {"ok": True, "users": rows}
|
||
|
||
@router.post("/admin/api/users/create")
|
||
def api_users_create(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip() or str(ctx.get("tenant_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
username = str(payload.get("username") or "").strip().lower()
|
||
display_name = str(payload.get("display_name") or username or "User").strip()
|
||
role = str(payload.get("role") or "member").strip() or "member"
|
||
password = str(payload.get("password") or "").strip()
|
||
if not username or not password:
|
||
return {"ok": False, "error": "username, password are required"}
|
||
existing = store.get_user_by_username(tenant_id=tenant_id, username=username)
|
||
if existing:
|
||
return {"ok": False, "error": "username_conflict"}
|
||
row = store.create_user_account(
|
||
tenant_id=tenant_id,
|
||
username=username,
|
||
display_name=display_name,
|
||
role=role,
|
||
password_hash=_sha256_hex(password),
|
||
is_active=True,
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="user_create",
|
||
target_type="user",
|
||
target_id=str(row.get("id") or ""),
|
||
status="ok",
|
||
detail={"username": username, "role": role},
|
||
)
|
||
return {"ok": True, "user": row}
|
||
|
||
@router.post("/admin/api/users/update")
|
||
def api_users_update(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
user_id = str(payload.get("user_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
if not tenant_id or not user_id:
|
||
return {"ok": False, "error": "tenant_id and user_id are required"}
|
||
target = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id)
|
||
if not target:
|
||
return {"ok": False, "error": "user_not_found"}
|
||
if str(target.get("username") or "").strip().lower() == "administrator" and payload.get("is_active") is False:
|
||
return {"ok": False, "error": "administrator_cannot_be_disabled"}
|
||
pwd = payload.get("password")
|
||
ok = store.update_user_account(
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
display_name=payload.get("display_name"),
|
||
role=payload.get("role"),
|
||
is_active=payload.get("is_active") if isinstance(payload.get("is_active"), bool) else None,
|
||
password_hash=_sha256_hex(str(pwd)) if isinstance(pwd, str) and pwd.strip() else None,
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="user_update",
|
||
target_type="user",
|
||
target_id=user_id,
|
||
status="ok" if ok else "miss",
|
||
detail={"fields": list(payload.keys())},
|
||
)
|
||
return {"ok": True, "updated": bool(ok)}
|
||
|
||
@router.post("/admin/api/users/delete")
|
||
def api_users_delete(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:delete")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
user_id = str(payload.get("user_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
if not tenant_id or not user_id:
|
||
return {"ok": False, "error": "tenant_id and user_id are required"}
|
||
target = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id)
|
||
if not target:
|
||
return {"ok": False, "error": "user_not_found"}
|
||
if str(target.get("username") or "").strip().lower() == "administrator":
|
||
return {"ok": False, "error": "administrator_cannot_be_deleted"}
|
||
if user_id == str(ctx.get("user_id") or ""):
|
||
return {"ok": False, "error": "cannot_delete_self"}
|
||
deleted = store.delete_user_account(tenant_id=tenant_id, user_id=user_id)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="user_delete",
|
||
target_type="user",
|
||
target_id=user_id,
|
||
status="ok" if deleted > 0 else "miss",
|
||
)
|
||
return {"ok": True, "deleted": deleted}
|
||
|
||
def _workspace_path_policy_read_mode(ctx: dict[str, Any]) -> str | None:
|
||
"""``full`` = any user in tenant; ``self`` = only own user_id; ``None`` = forbidden."""
|
||
if str(ctx.get("role") or "") == "owner":
|
||
return "full"
|
||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||
if "admin:user:read" in perms:
|
||
return "full"
|
||
if "admin:workspace_paths:read" in perms:
|
||
return "self"
|
||
return None
|
||
|
||
def _workspace_path_policy_write_mode(ctx: dict[str, Any]) -> str | None:
|
||
if str(ctx.get("role") or "") == "owner":
|
||
return "full"
|
||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||
if "admin:user:write" in perms:
|
||
return "full"
|
||
if "admin:workspace_paths:write" in perms:
|
||
return "self"
|
||
return None
|
||
|
||
def _normalize_workspace_extra_roots(raw: str) -> tuple[str, str | None]:
|
||
"""Split ``|``, resolve each segment; must be absolute. Returns (joined, error_code)."""
|
||
parts = [x.strip().strip('"').strip("'") for x in str(raw or "").split("|") if str(x).strip()]
|
||
if not parts:
|
||
return "", None
|
||
out: list[str] = []
|
||
for p in parts:
|
||
try:
|
||
rp = Path(p).expanduser().resolve()
|
||
if not rp.is_absolute():
|
||
return "", "extra_roots_must_be_absolute"
|
||
out.append(str(rp))
|
||
except Exception:
|
||
return "", "extra_roots_invalid_path"
|
||
return "|".join(out), None
|
||
|
||
@router.get("/admin/api/users/workspace-path-policy")
|
||
def api_users_workspace_path_policy_get(
|
||
tenant_id: str = Query(default=""),
|
||
user_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
rmode = _workspace_path_policy_read_mode(ctx)
|
||
if rmode is None:
|
||
raise HTTPException(status_code=403, detail="forbidden")
|
||
tid = str(tenant_id or "").strip()
|
||
uid = str(user_id or "").strip()
|
||
_require_tenant_scope(ctx, tid)
|
||
if not tid or not uid:
|
||
return {"ok": False, "error": "tenant_id and user_id are required"}
|
||
if rmode == "self" and (tid != str(ctx.get("tenant_id") or "") or uid != str(ctx.get("user_id") or "")):
|
||
raise HTTPException(status_code=403, detail="workspace_paths_self_only")
|
||
row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid)
|
||
if not row:
|
||
return {
|
||
"ok": True,
|
||
"from_db": False,
|
||
"policy": {"extra_roots": "", "allow_any_path": False},
|
||
"public_tools_allow_high": False,
|
||
}
|
||
return {
|
||
"ok": True,
|
||
"from_db": True,
|
||
"policy": row,
|
||
"public_tools_allow_high": bool(row.get("allow_high_risk_public_tools")),
|
||
}
|
||
|
||
@router.post("/admin/api/users/workspace-path-policy")
|
||
def api_users_workspace_path_policy_save(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
wmode = _workspace_path_policy_write_mode(ctx)
|
||
if wmode is None:
|
||
raise HTTPException(status_code=403, detail="forbidden")
|
||
tid = str(payload.get("tenant_id") or "").strip()
|
||
uid = str(payload.get("user_id") or "").strip()
|
||
_require_tenant_scope(ctx, tid)
|
||
if not tid or not uid:
|
||
return {"ok": False, "error": "tenant_id and user_id are required"}
|
||
if wmode == "self" and (tid != str(ctx.get("tenant_id") or "") or uid != str(ctx.get("user_id") or "")):
|
||
raise HTTPException(status_code=403, detail="workspace_paths_self_only")
|
||
if not store.get_user_by_id(tenant_id=tid, user_id=uid):
|
||
return {"ok": False, "error": "user_not_found"}
|
||
extra_raw = str(payload.get("extra_roots") or "")
|
||
norm, err = _normalize_workspace_extra_roots(extra_raw)
|
||
if err:
|
||
return {"ok": False, "error": err}
|
||
allow_any = bool(payload.get("allow_any_path", False))
|
||
allow_high = bool(payload.get("public_tools_allow_high", False))
|
||
store.upsert_user_workspace_path_allowlist(
|
||
tenant_id=tid,
|
||
user_id=uid,
|
||
extra_roots=norm,
|
||
allow_any_path=allow_any,
|
||
allow_high_risk_public_tools=allow_high,
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="user_workspace_path_policy_save",
|
||
target_type="user",
|
||
target_id=uid,
|
||
status="ok",
|
||
detail={
|
||
"tenant_id": tid,
|
||
"allow_any_path": allow_any,
|
||
"public_tools_allow_high": bool(allow_high),
|
||
"extra_roots_preview": norm[:500],
|
||
},
|
||
)
|
||
row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid)
|
||
return {"ok": True, "policy": row or {}, "public_tools_allow_high": bool((row or {}).get("allow_high_risk_public_tools"))}
|
||
|
||
@router.post("/admin/api/users/delete-unbound")
|
||
def api_users_delete_unbound(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:delete")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
channel = str(payload.get("channel") or "wecom").strip() or "wecom"
|
||
if not tenant_id:
|
||
return {"ok": False, "error": "tenant_id is required"}
|
||
users = store.list_users(tenant_id=tenant_id, limit=5000)
|
||
if str(channel).strip().lower() == "wecom":
|
||
binds = store.list_channel_identities_v2(tenant_id=tenant_id, channel=channel, limit=5000)
|
||
else:
|
||
binds = store.list_channel_identities(tenant_id=tenant_id, channel=channel, limit=5000)
|
||
bound_user_ids = {str(b.get("user_id") or "") for b in binds}
|
||
orphan_ids = [
|
||
str(u.get("id") or "")
|
||
for u in users
|
||
if str(u.get("id") or "") not in bound_user_ids
|
||
and str(u.get("username") or "").strip().lower() != "administrator"
|
||
]
|
||
deleted = 0
|
||
if orphan_ids:
|
||
with store._connect() as conn:
|
||
for uid in orphan_ids:
|
||
cur = conn.execute(
|
||
"DELETE FROM app_user WHERE tenant_id = ? AND id = ?",
|
||
(tenant_id, uid),
|
||
)
|
||
deleted += int(cur.rowcount or 0)
|
||
return {
|
||
"ok": True,
|
||
"deleted": deleted,
|
||
"users_total": len(users),
|
||
"bound_users": len([x for x in bound_user_ids if x]),
|
||
"orphan_users": len(orphan_ids),
|
||
}
|
||
|
||
@router.get("/admin/api/bind-codes")
|
||
def api_bind_codes(tenant_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:read")
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
rows = store.list_bind_codes(tenant_id=tenant_id, limit=200)
|
||
return {"ok": True, "codes": rows}
|
||
|
||
@router.post("/admin/api/bind-codes/create")
|
||
def api_bind_codes_create(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
role = str(payload.get("role") or "member").strip() or "member"
|
||
if not tenant_id:
|
||
return {"ok": False, "error": "tenant_id is required"}
|
||
code = str(payload.get("code") or "").strip() or secrets.token_urlsafe(6)
|
||
row = store.create_bind_code(tenant_id=tenant_id, role=role, code=code)
|
||
return {"ok": True, "code": row}
|
||
|
||
@router.post("/admin/api/bind-codes/delete")
|
||
def api_bind_codes_delete(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
code = str(payload.get("code") or "").strip()
|
||
if not tenant_id:
|
||
return {"ok": False, "error": "tenant_id is required"}
|
||
if not code:
|
||
return {"ok": False, "error": "code is required"}
|
||
with store._connect() as conn:
|
||
cur = conn.execute(
|
||
"DELETE FROM bind_code WHERE tenant_id = ? AND code = ?",
|
||
(tenant_id, code),
|
||
)
|
||
deleted = int(cur.rowcount or 0)
|
||
return {"ok": True, "deleted": deleted}
|
||
|
||
@router.get("/admin/api/wecom/config")
|
||
def api_wecom_config(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
return {
|
||
"ok": True,
|
||
"config": {
|
||
"wecom_mode": "bot_api",
|
||
"wecom_bot_id": str(store.get_setting("wecom_bot_id") or ""),
|
||
"wecom_bot_secret": str(store.get_secret("wecom_bot_secret") or ""),
|
||
"wecom_auto_bind_enabled": str(store.get_setting("wecom_auto_bind_enabled") or "1"),
|
||
"wecom_auto_bind_tenant_name": str(store.get_setting("wecom_auto_bind_tenant_name") or "Team"),
|
||
"wecom_auto_bind_role": str(store.get_setting("wecom_auto_bind_role") or "member"),
|
||
},
|
||
}
|
||
|
||
@router.get("/admin/api/wecom/health")
|
||
def api_wecom_health(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
svc = next((s for s in status_services() if str(s.name) == "channel:wecom"), None)
|
||
def _gs(k: str) -> str:
|
||
return str(store.get_setting(k) or "").strip()
|
||
recent_raw = _gs("wecom_recent_from_users")
|
||
try:
|
||
recent = json.loads(recent_raw) if recent_raw else []
|
||
if not isinstance(recent, list):
|
||
recent = []
|
||
except Exception:
|
||
recent = []
|
||
return {
|
||
"ok": True,
|
||
"service": {
|
||
"name": "channel:wecom",
|
||
"running": bool(getattr(svc, "running", False)) if svc else False,
|
||
"pid": int(getattr(svc, "pid", 0) or 0) if svc else 0,
|
||
},
|
||
"inbound": {
|
||
"last_msg_ts": _gs("wecom_last_msg_ts"),
|
||
"last_from_user": _gs("wecom_last_from_user"),
|
||
"recent_from_users": recent[:20],
|
||
"last_cmd": _gs("wecom_last_cmd"),
|
||
"last_parse_error": _gs("wecom_last_parse_error"),
|
||
},
|
||
"outbound": {
|
||
"last_mode": _gs("wecom_last_outbound_mode"),
|
||
"last_error": _gs("wecom_last_outbound_error"),
|
||
"last_ack_req_id": _gs("wecom_last_ack_req_id"),
|
||
"last_ack_errcode": _gs("wecom_last_ack_errcode"),
|
||
"last_ack_errmsg": _gs("wecom_last_ack_errmsg"),
|
||
},
|
||
"raw": {
|
||
"last_raw_from_user": _gs("wecom_last_raw_from_user"),
|
||
"last_raw_body": _gs("wecom_last_raw_body"),
|
||
"last_unknown_cmd_payload": _gs("wecom_last_unknown_cmd_payload"),
|
||
},
|
||
}
|
||
|
||
@router.post("/admin/api/wecom/config")
|
||
def api_wecom_config_save(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
store.set_setting("wecom_mode", "bot_api")
|
||
store.set_setting("wecom_bot_id", str(payload.get("wecom_bot_id") or "").strip())
|
||
bot_secret = str(payload.get("wecom_bot_secret") or "").strip()
|
||
if bot_secret:
|
||
store.set_secret("wecom_bot_secret", bot_secret)
|
||
auto_enabled = str(payload.get("wecom_auto_bind_enabled") or "").strip().lower()
|
||
enabled_truthy = auto_enabled in ("1", "true", "yes", "on")
|
||
store.set_setting("wecom_auto_bind_enabled", "1" if enabled_truthy else "0")
|
||
store.set_setting(
|
||
"wecom_auto_bind_tenant_name",
|
||
str(payload.get("wecom_auto_bind_tenant_name") or "").strip() or "Team",
|
||
)
|
||
store.set_setting(
|
||
"wecom_auto_bind_role",
|
||
str(payload.get("wecom_auto_bind_role") or "").strip() or "member",
|
||
)
|
||
return api_wecom_config()
|
||
|
||
@router.post("/admin/api/wecom/unbind")
|
||
def api_wecom_unbind(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
with store._connect() as conn:
|
||
cur_ident = conn.execute("DELETE FROM channel_identity WHERE channel = ?", ("wecom",))
|
||
ident_deleted = int(cur_ident.rowcount or 0)
|
||
cur_sess = conn.execute("DELETE FROM channel_session WHERE channel = ?", ("wecom",))
|
||
sess_deleted = int(cur_sess.rowcount or 0)
|
||
for k in _WECOM_CLEAR_KEYS:
|
||
store.delete_setting(k)
|
||
with store._connect() as conn:
|
||
conn.execute(
|
||
"DELETE FROM app_setting WHERE key LIKE 'wecom:bot_secret:%' OR key LIKE 'wecom:agent_secret:%'"
|
||
)
|
||
cfg = api_wecom_config()
|
||
return {
|
||
"ok": True,
|
||
"deleted_channel_identity": ident_deleted,
|
||
"deleted_channel_session": sess_deleted,
|
||
"config": cfg.get("config", {}),
|
||
}
|
||
|
||
@router.get("/admin/api/audit")
|
||
def api_audit(
|
||
session_id: str | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
rows = store.list_agent_audit_logs(limit=200, session_id=session_id)
|
||
return {"ok": True, "audit": rows}
|
||
|
||
@router.get("/admin/api/audit/session-health")
|
||
def api_audit_session_health(
|
||
session_id: str | None = Query(default=None),
|
||
limit: int = Query(default=80),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
rows = store.list_session_tool_health(session_id=session_id, limit=limit)
|
||
warn = [x for x in rows if str(x.get("status") or "") == "warn_no_tool_calls"]
|
||
return {"ok": True, "items": rows, "warn_count": len(warn)}
|
||
|
||
@router.get("/admin/api/trace")
|
||
def api_trace(
|
||
session_id: str | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
if not session_id:
|
||
return {"ok": True, "trace": []}
|
||
rows = store.list_trace_events(session_id=session_id, limit=300)
|
||
return {"ok": True, "trace": rows}
|
||
|
||
@router.get("/admin/api/oclaw/tasks")
|
||
@router.get("/admin/api/oclaw/tasks")
|
||
def api_oclaw_tasks(
|
||
task_id: str | None = Query(default=None),
|
||
session_id: str | None = Query(default=None),
|
||
status: str | None = Query(default=None),
|
||
limit: int = Query(default=80),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
sid = str(session_id or "").strip() or None
|
||
st = str(status or "").strip() or None
|
||
if task_id:
|
||
task = store.oclaw_task_get(task_id=str(task_id), tenant_id=tenant_id)
|
||
return {"ok": True, "task": None if not task else task.__dict__}
|
||
rows = store.oclaw_task_list(
|
||
status=st,
|
||
session_id=sid,
|
||
tenant_id=tenant_id,
|
||
limit=max(1, min(int(limit or 80), 300)),
|
||
)
|
||
by_status: dict[str, int] = {"queued": 0, "claimed": 0, "done": 0, "failed": 0}
|
||
for r in rows:
|
||
s = str(r.status or "")
|
||
by_status[s] = int(by_status.get(s, 0)) + 1
|
||
return {"ok": True, "tasks": [r.__dict__ for r in rows], "counts": by_status}
|
||
|
||
@router.get("/admin/api/oclaw/runs")
|
||
@router.get("/admin/api/oclaw/runs")
|
||
def api_oclaw_runs(
|
||
run_id: str | None = Query(default=None),
|
||
session_id: str | None = Query(default=None),
|
||
status: str | None = Query(default=None),
|
||
limit: int = Query(default=80),
|
||
include_attempts: int = Query(default=1),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
if run_id:
|
||
row = store.oclaw_run_get(run_id=str(run_id), tenant_id=tenant_id)
|
||
if not row:
|
||
return {"ok": True, "run": None}
|
||
out = dict(row.__dict__)
|
||
if int(include_attempts or 0):
|
||
out["attempts"] = store.oclaw_attempt_list(run_id=row.run_id, limit=50)
|
||
return {"ok": True, "run": out}
|
||
rows = store.oclaw_run_list(
|
||
tenant_id=tenant_id,
|
||
session_id=str(session_id or "").strip() or None,
|
||
status=str(status or "").strip() or None,
|
||
limit=max(1, min(int(limit or 80), 300)),
|
||
)
|
||
by_status: dict[str, int] = {"running": 0, "success": 0, "failed": 0}
|
||
out_rows = []
|
||
for r in rows:
|
||
s = str(r.status or "")
|
||
by_status[s] = int(by_status.get(s, 0)) + 1
|
||
item = dict(r.__dict__)
|
||
if int(include_attempts or 0):
|
||
item["attempts"] = store.oclaw_attempt_list(run_id=r.run_id, limit=20)
|
||
out_rows.append(item)
|
||
configured_retryable = sorted(resolve_retryable_error_codes(store=store))
|
||
return {
|
||
"ok": True,
|
||
"runs": out_rows,
|
||
"counts": by_status,
|
||
"retry_policy": {
|
||
"setting_key": "AIA_OCLAW_RETRYABLE_ERROR_CODES",
|
||
"effective_retryable_error_codes": configured_retryable,
|
||
"default_retryable_error_codes": list(DEFAULT_RETRYABLE_ERROR_CODES),
|
||
},
|
||
}
|
||
|
||
@router.get("/admin/api/scheduled-jobs/stats")
|
||
def api_scheduled_jobs_stats(
|
||
recent_limit: int = Query(default=80),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
summary = store.scheduled_job_failure_summary(
|
||
tenant_id=tenant_id,
|
||
recent_limit=max(10, min(int(recent_limit or 80), 300)),
|
||
)
|
||
return {"ok": True, **summary}
|
||
|
||
@router.get("/admin/api/scheduled-jobs/meta/recipe-templates")
|
||
def api_scheduled_jobs_meta_recipe_templates(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.scheduler.recipe import list_ops_recipe_templates
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
return {"ok": True, "items": list_ops_recipe_templates()}
|
||
|
||
@router.get("/admin/api/scheduled-jobs/meta/targets")
|
||
def api_scheduled_jobs_meta_targets(
|
||
tenant_id: str = Query(default=""),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.scheduler.session_resolver import resolve_weixin_binding
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tid = str(tenant_id or ctx.get("tenant_id") or "default").strip()
|
||
_require_tenant_scope(ctx, tid)
|
||
aid = str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||
known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid)
|
||
weixin = resolve_weixin_binding(store, tenant_id=tid)
|
||
return {"ok": True, "whatsapp_groups": known, "weixin_binding": weixin, "whatsapp_account_id": aid}
|
||
|
||
@router.get("/admin/api/scheduled-jobs")
|
||
def api_scheduled_jobs_list(
|
||
status: str | None = Query(default=None),
|
||
limit: int = Query(default=100),
|
||
offset: int = Query(default=0),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
rows = store.scheduled_job_list(
|
||
tenant_id=tenant_id,
|
||
status=str(status or "").strip() or None,
|
||
limit=max(1, min(int(limit or 100), 300)),
|
||
offset=max(0, int(offset or 0)),
|
||
)
|
||
return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]}
|
||
|
||
@router.get("/admin/api/scheduled-jobs/{job_id}")
|
||
def api_scheduled_jobs_get(
|
||
job_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
row = store.scheduled_job_get(job_id=str(job_id), tenant_id=tenant_id)
|
||
return {"ok": True, "job": None if not row else store.scheduled_job_to_dict(row)}
|
||
|
||
@router.post("/admin/api/scheduled-jobs")
|
||
def api_scheduled_jobs_create(
|
||
payload: dict[str, Any],
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.scheduler.cron_service import build_default_delivery
|
||
from runtime.scheduler.expressions import normalize_schedule_kind
|
||
from runtime.scheduler.system_timezone import default_system_timezone
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
name = str(payload.get("name") or "").strip()
|
||
prompt_text = str(payload.get("prompt_text") or "").strip()
|
||
schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron")
|
||
schedule_expr = str(payload.get("schedule_expr") or "").strip()
|
||
if not name or not schedule_expr:
|
||
return {"ok": False, "error": "name, schedule_expr are required"}
|
||
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None
|
||
if delivery is None:
|
||
wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "")
|
||
delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat)
|
||
from runtime.scheduler.recipe import (
|
||
list_ops_recipe_templates,
|
||
normalize_recipe,
|
||
prompt_summary_from_recipe,
|
||
recipe_has_playbook,
|
||
resolve_ops_recipe_template,
|
||
synthesize_recipe_from_prompt,
|
||
)
|
||
|
||
recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else None
|
||
if isinstance(recipe, dict):
|
||
recipe = normalize_recipe(recipe)
|
||
template_id = str(payload.get("recipe_template_id") or payload.get("template_id") or "").strip()
|
||
if template_id and not recipe_has_playbook(recipe):
|
||
tmpl = resolve_ops_recipe_template(template_id)
|
||
if tmpl is None:
|
||
return {
|
||
"ok": False,
|
||
"error": "unknown_recipe_template",
|
||
"template_id": template_id,
|
||
"available": list_ops_recipe_templates(),
|
||
}
|
||
recipe = tmpl
|
||
if recipe_has_playbook(recipe) and not prompt_text:
|
||
prompt_text = prompt_summary_from_recipe(recipe, fallback=name)
|
||
if not prompt_text:
|
||
return {"ok": False, "error": "name, prompt_text, schedule_expr are required"}
|
||
if not recipe_has_playbook(recipe):
|
||
synth = synthesize_recipe_from_prompt(
|
||
prompt_text,
|
||
session_id=str(payload.get("source_session_id") or ""),
|
||
)
|
||
if recipe_has_playbook(synth):
|
||
recipe = synth
|
||
if not str((recipe.get("source") or {}).get("compiled_at") or "").strip():
|
||
from datetime import datetime, timezone
|
||
|
||
recipe.setdefault("source", {})["compiled_at"] = datetime.now(timezone.utc).isoformat()
|
||
# WhatsApp field ops default to English when delivery targets WA and lang omitted.
|
||
lang = str(payload.get("lang") or "").strip()
|
||
if not lang:
|
||
ch = str((delivery or {}).get("channel") or "").strip().lower()
|
||
lang = "en" if ch in {"whatsapp", "wa"} else "zh"
|
||
row = store.scheduled_job_create(
|
||
tenant_id=tenant_id,
|
||
name=name,
|
||
prompt_text=prompt_text,
|
||
schedule_kind=schedule_kind,
|
||
schedule_expr=schedule_expr,
|
||
timezone_name=str(payload.get("timezone") or default_system_timezone()),
|
||
description=str(payload.get("description") or ""),
|
||
interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"),
|
||
specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"),
|
||
lang=lang,
|
||
delivery=delivery,
|
||
recipe=recipe,
|
||
source_session_id=str(payload.get("source_session_id") or "").strip() or None,
|
||
created_by_user_id=str(ctx.get("user_id") or ""),
|
||
source="admin",
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=tenant_id,
|
||
actor_user_id=str(ctx.get("user_id") or ""),
|
||
action="scheduled_job_create",
|
||
target_type="scheduled_job",
|
||
target_id=row.id,
|
||
status="ok",
|
||
)
|
||
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
|
||
|
||
@router.patch("/admin/api/scheduled-jobs/{job_id}")
|
||
def api_scheduled_jobs_patch(
|
||
job_id: str,
|
||
payload: dict[str, Any],
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
row = store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=dict(payload or {}))
|
||
if not row:
|
||
return {"ok": False, "error": "job_not_found"}
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=tenant_id,
|
||
actor_user_id=str(ctx.get("user_id") or ""),
|
||
action="scheduled_job_update",
|
||
target_type="scheduled_job",
|
||
target_id=str(job_id),
|
||
status="ok",
|
||
)
|
||
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
|
||
|
||
@router.post("/admin/api/scheduled-jobs/{job_id}/pause")
|
||
def api_scheduled_jobs_pause(
|
||
job_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="paused")
|
||
return {"ok": bool(ok)}
|
||
|
||
@router.post("/admin/api/scheduled-jobs/{job_id}/resume")
|
||
def api_scheduled_jobs_resume(
|
||
job_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="active")
|
||
return {"ok": bool(ok)}
|
||
|
||
@router.delete("/admin/api/scheduled-jobs/{job_id}")
|
||
def api_scheduled_jobs_delete(
|
||
job_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(job_id))
|
||
return {"ok": bool(ok)}
|
||
|
||
@router.post("/admin/api/scheduled-jobs/{job_id}/run-now")
|
||
def api_scheduled_jobs_run_now(
|
||
job_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
from runtime.scheduler.service import run_scheduled_job_now
|
||
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:runtime:write")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=str(job_id))
|
||
return out
|
||
|
||
@router.get("/admin/api/scheduled-jobs/{job_id}/runs")
|
||
def api_scheduled_jobs_runs(
|
||
job_id: str,
|
||
limit: int = Query(default=50),
|
||
offset: int = Query(default=0),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
rows = store.scheduled_job_run_list(
|
||
job_id=str(job_id),
|
||
tenant_id=tenant_id,
|
||
limit=max(1, min(int(limit or 50), 200)),
|
||
offset=max(0, int(offset or 0)),
|
||
)
|
||
return {"ok": True, "items": [store.scheduled_job_run_to_dict(r) for r in rows]}
|
||
|
||
@router.get("/admin/api/replay/turn")
|
||
def api_replay_turn(
|
||
session_id: str,
|
||
trace_id: str,
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
"""Return a best-effort replay bundle for a single turn (trace_id)."""
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
sid = str(session_id or "").strip()
|
||
tid = str(trace_id or "").strip()
|
||
if not sid or not tid:
|
||
return {"ok": False, "error": "session_id_and_trace_id_required"}
|
||
# Restrict non-administrator users to their own sessions only.
|
||
uname = str(ctx.get("username") or "").strip().lower()
|
||
if uname != "administrator":
|
||
# Must be owned by current user in current tenant.
|
||
owner = store.get_session_for_user(
|
||
session_id=sid, tenant_id=str(ctx.get("tenant_id") or ""), user_id=str(ctx.get("user_id") or "")
|
||
)
|
||
if not owner:
|
||
raise HTTPException(status_code=403, detail="forbidden")
|
||
|
||
trace_rows = store.list_trace_events_for_trace(session_id=sid, trace_id=tid, limit=800)
|
||
start_ts, end_ts = store.get_turn_time_window(session_id=sid, trace_id=tid)
|
||
msgs = store.list_messages_in_time_window(session_id=sid, start_ts=start_ts, end_ts=end_ts, limit=1200)
|
||
def _to_ms(v: Any) -> int | None:
|
||
s = str(v or "").strip()
|
||
if not s:
|
||
return None
|
||
try:
|
||
if s.endswith("Z"):
|
||
s = s[:-1] + "+00:00"
|
||
return int(datetime.fromisoformat(s).timestamp() * 1000)
|
||
except Exception:
|
||
return None
|
||
|
||
ws_accepted_ms: int | None = None
|
||
gateway_received_ms: int | None = None
|
||
model_chat_start_ms: int | None = None
|
||
ws_first_token_ms: int | None = None
|
||
for row in trace_rows or []:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
event_type = str(row.get("event_type") or "").strip()
|
||
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||
row_ts = _to_ms(row.get("timestamp"))
|
||
if event_type == "gateway_received":
|
||
gateway_received_ms = row_ts if row_ts is not None else gateway_received_ms
|
||
if ws_accepted_ms is None:
|
||
try:
|
||
v = payload.get("ws_client_send_ms")
|
||
if v is not None:
|
||
ws_accepted_ms = int(v)
|
||
except Exception:
|
||
pass
|
||
elif event_type == "model_chat_start":
|
||
model_chat_start_ms = row_ts if row_ts is not None else model_chat_start_ms
|
||
elif event_type == "ws_first_token":
|
||
if ws_first_token_ms is None:
|
||
try:
|
||
vft = payload.get("ws_first_token_ms")
|
||
if vft is not None:
|
||
ws_first_token_ms = int(vft)
|
||
except Exception:
|
||
pass
|
||
if ws_accepted_ms is None:
|
||
try:
|
||
va = payload.get("ws_accepted_ms")
|
||
if va is not None:
|
||
ws_accepted_ms = int(va)
|
||
except Exception:
|
||
pass
|
||
if ws_first_token_ms is None:
|
||
ws_first_token_ms = row_ts if row_ts is not None else ws_first_token_ms
|
||
|
||
def _delta(a: int | None, b: int | None) -> int | None:
|
||
if a is None or b is None:
|
||
return None
|
||
d = int(b - a)
|
||
return d if d >= 0 else None
|
||
|
||
ttft = {
|
||
"ws_accepted_ms": ws_accepted_ms,
|
||
"gateway_received_ms": gateway_received_ms,
|
||
"model_chat_start_ms": model_chat_start_ms,
|
||
"ws_first_token_ms": ws_first_token_ms,
|
||
"accepted_to_gateway_ms": _delta(ws_accepted_ms, gateway_received_ms),
|
||
"gateway_to_model_start_ms": _delta(gateway_received_ms, model_chat_start_ms),
|
||
"model_start_to_first_token_ms": _delta(model_chat_start_ms, ws_first_token_ms),
|
||
"gateway_to_first_token_ms": _delta(gateway_received_ms, ws_first_token_ms),
|
||
"accepted_to_first_token_ms": _delta(ws_accepted_ms, ws_first_token_ms),
|
||
}
|
||
return {
|
||
"ok": True,
|
||
"session_id": sid,
|
||
"trace_id": tid,
|
||
"start_ts": start_ts,
|
||
"end_ts": end_ts,
|
||
"ttft": ttft,
|
||
"trace": trace_rows,
|
||
"messages": msgs,
|
||
}
|
||
|
||
@router.get("/admin/api/plugins")
|
||
def api_plugins(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
rows = store.list_tool_plugins()
|
||
return {"ok": True, "plugins": rows}
|
||
|
||
@router.get("/admin/api/tool-policy")
|
||
def api_tool_policy(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
# Oclaw takeover: legacy tool-policy switches removed (fixed display values for API compat).
|
||
disabled = False
|
||
retry_mode = "first_round_only"
|
||
loop_state_machine = True
|
||
signature_budget = 2
|
||
tmw_raw = str(store.get_setting("AIA_TURN_MAX_TOOL_WORKERS") or "").strip()
|
||
tmr_raw = str(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or "").strip()
|
||
tmc_raw = str(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or "").strip()
|
||
turn_max_tool_workers = max(1, min(int(tmw_raw), 32)) if tmw_raw.isdigit() else 8
|
||
turn_max_tool_rounds = max(1, min(int(tmr_raw), 300)) if tmr_raw.isdigit() else 100
|
||
turn_max_context_messages = max(10, min(int(tmc_raw), 400)) if tmc_raw.isdigit() else 80
|
||
turn_runner_impl = "oclaw"
|
||
manager_decision_mode = ""
|
||
sse_raw = str(store.get_setting("AIA_SSE_QUEUE_MAXSIZE") or "").strip()
|
||
sse_queue_maxsize = max(200, min(int(sse_raw), 50_000)) if sse_raw.isdigit() else 2000
|
||
tl_raw = str(store.get_setting("AIA_TOOL_LOG_MAX_CHARS") or "").strip()
|
||
tool_log_max_chars = max(20_000, min(int(tl_raw), 2_000_000)) if tl_raw.isdigit() else 64_000
|
||
emcp_raw = str(store.get_setting("AIA_ENABLE_MCP_TOOLS") or "").strip().lower()
|
||
enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off")
|
||
# Align with catalog._plugin_tools_enabled: unset → ON (env may override).
|
||
epl_sv = store.get_setting("AIA_ENABLE_PLUGIN_TOOLS")
|
||
if epl_sv is None or str(epl_sv).strip() == "":
|
||
import os as _os
|
||
|
||
epl_env = str(
|
||
_os.getenv("AIA_ENABLE_PLUGIN_TOOLS")
|
||
or _os.getenv("AIA_PLUGIN_TOOLS_ENABLED")
|
||
or "1"
|
||
).strip().lower()
|
||
enable_plugin_tools = epl_env not in ("0", "false", "no", "off")
|
||
else:
|
||
epl_raw = str(epl_sv).strip().lower()
|
||
enable_plugin_tools = epl_raw in ("1", "true", "yes", "on")
|
||
# Absent DB row must read as OFF: matches LocalAdapter.run_command (no row → env only; no env → disabled).
|
||
# Previously `str(None or "")` made the UI show ON while execution stayed disabled (confusing on new machines).
|
||
erc_sv = store.get_setting("AIA_ENABLE_RUN_COMMAND")
|
||
if erc_sv is None:
|
||
enable_run_command = False
|
||
else:
|
||
erc_raw = str(erc_sv).strip().lower()
|
||
enable_run_command = erc_raw not in ("0", "false", "no", "off")
|
||
tctx_raw = str(store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower()
|
||
tool_context_truncate_enabled = tctx_raw not in ("0", "false", "no", "off")
|
||
ttft_raw = str(store.get_setting("AIA_CHAT_SHOW_TTFT_DEBUG") or "").strip().lower()
|
||
# default OFF (release-safe): only enabled when explicitly set truthy.
|
||
chat_show_ttft_debug = ttft_raw in ("1", "true", "yes", "on")
|
||
skl_raw = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower()
|
||
skill_runtime_enabled = skl_raw not in ("0", "false", "no", "off")
|
||
sai_raw = str(store.get_setting("AIA_SKILL_AUTO_INSTALL_ENABLED") or "").strip().lower()
|
||
skill_auto_install_enabled = sai_raw not in ("0", "false", "no", "off")
|
||
tmsg_raw = str(store.get_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS") or "").strip()
|
||
if tmsg_raw.isdigit():
|
||
_n = int(tmsg_raw)
|
||
tool_llm_message_max_chars = 0 if _n == 0 else max(4096, min(_n, 500_000))
|
||
else:
|
||
tool_llm_message_max_chars = 0
|
||
mcp_fs_raw = str(store.get_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS") or "").strip()
|
||
mcp_env_allow_raw = str(store.get_setting("AIA_MCP_ENV_ALLOWLIST") or "").strip()
|
||
retry_codes_raw = str(store.get_setting("AIA_OCLAW_RETRYABLE_ERROR_CODES") or "").strip()
|
||
if not retry_codes_raw:
|
||
retry_codes_raw = ",".join(DEFAULT_RETRYABLE_ERROR_CODES)
|
||
retry_strict_raw = str(store.get_setting("AIA_OCLAW_RETRY_CODES_STRICT_MODE") or "").strip().lower()
|
||
retry_codes_strict_mode = retry_strict_raw in ("1", "true", "yes", "on")
|
||
wc_workers_raw = str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
|
||
if not wc_workers_raw:
|
||
wc_workers_raw = str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip()
|
||
wc_in_q_raw = str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||
if not wc_in_q_raw:
|
||
wc_in_q_raw = str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||
wecom_longconn_workers = max(1, min(int(wc_workers_raw), 8)) if wc_workers_raw.isdigit() else 2
|
||
wecom_longconn_inbound_queue_maxsize = max(20, min(int(wc_in_q_raw), 5000)) if wc_in_q_raw.isdigit() else 200
|
||
return {
|
||
"ok": True,
|
||
"disable_tool_confirm": disabled,
|
||
"enforced_retry_mode": retry_mode,
|
||
"tool_loop_state_machine": loop_state_machine,
|
||
"tool_signature_budget": signature_budget,
|
||
"turn_max_tool_workers": turn_max_tool_workers,
|
||
"turn_max_tool_rounds": turn_max_tool_rounds,
|
||
"turn_max_context_messages": turn_max_context_messages,
|
||
"turn_runner_impl": turn_runner_impl,
|
||
"manager_decision_mode": manager_decision_mode,
|
||
"sse_queue_maxsize": sse_queue_maxsize,
|
||
"tool_log_max_chars": tool_log_max_chars,
|
||
"enable_mcp_tools": enable_mcp_tools,
|
||
"enable_plugin_tools": enable_plugin_tools,
|
||
"enable_run_command": enable_run_command,
|
||
"tool_context_truncate_enabled": tool_context_truncate_enabled,
|
||
"chat_show_ttft_debug": chat_show_ttft_debug,
|
||
"skill_runtime_enabled": skill_runtime_enabled,
|
||
"skill_auto_install_enabled": skill_auto_install_enabled,
|
||
"tool_llm_message_max_chars": tool_llm_message_max_chars,
|
||
"mcp_filesystem_extra_roots": mcp_fs_raw,
|
||
"mcp_env_allowlist": mcp_env_allow_raw,
|
||
"oclaw_retryable_error_codes": retry_codes_raw,
|
||
"oclaw_retry_codes_strict_mode": retry_codes_strict_mode,
|
||
"wecom_longconn_workers": wecom_longconn_workers,
|
||
"wecom_longconn_inbound_queue_maxsize": wecom_longconn_inbound_queue_maxsize,
|
||
}
|
||
|
||
@router.post("/admin/api/tool-policy")
|
||
def api_tool_policy_save(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
# Legacy tool-policy switches removed from product surface (fixed values for API compat).
|
||
disable_confirm = False
|
||
retry_mode = "first_round_only"
|
||
sm = True
|
||
sig_budget_n = 2
|
||
tmw = payload.get("turn_max_tool_workers", 8)
|
||
tmr = payload.get("turn_max_tool_rounds", 100)
|
||
tmc = payload.get("turn_max_context_messages", 80)
|
||
tri = "oclaw"
|
||
try:
|
||
tmw_n = max(1, min(int(tmw), 32))
|
||
except Exception:
|
||
tmw_n = 8
|
||
try:
|
||
tmr_n = max(1, min(int(tmr), 300))
|
||
except Exception:
|
||
tmr_n = 100
|
||
try:
|
||
tmc_n = max(10, min(int(tmc), 400))
|
||
except Exception:
|
||
tmc_n = 80
|
||
md = ""
|
||
sse_q = payload.get("sse_queue_maxsize", 2000)
|
||
tl_cap = payload.get("tool_log_max_chars", 64000)
|
||
try:
|
||
sse_q_n = max(200, min(int(sse_q), 50_000))
|
||
except Exception:
|
||
sse_q_n = 2000
|
||
try:
|
||
tl_cap_n = max(20_000, min(int(tl_cap), 2_000_000))
|
||
except Exception:
|
||
tl_cap_n = 64_000
|
||
emcp = bool(payload.get("enable_mcp_tools", True))
|
||
epl = bool(payload.get("enable_plugin_tools", True))
|
||
erc = bool(payload.get("enable_run_command", True))
|
||
tctx = bool(payload.get("tool_context_truncate_enabled", True))
|
||
ttft_debug = bool(payload.get("chat_show_ttft_debug", False))
|
||
skl_rt = bool(payload.get("skill_runtime_enabled", True))
|
||
skl_ai = bool(payload.get("skill_auto_install_enabled", True))
|
||
tmsg = payload.get("tool_llm_message_max_chars", 0)
|
||
mcp_fs_roots = str(payload.get("mcp_filesystem_extra_roots") or "").strip()
|
||
mcp_env_allow = str(payload.get("mcp_env_allowlist") or "").strip()
|
||
retry_codes = str(payload.get("oclaw_retryable_error_codes") or "").strip()
|
||
retry_codes_strict_mode = bool(payload.get("oclaw_retry_codes_strict_mode", False))
|
||
unknown_retry_codes: list[str] = []
|
||
if retry_codes:
|
||
vals = [x.strip().lower() for x in retry_codes.split(",") if x and x.strip()]
|
||
allowed = set(ALL_ATTEMPT_ERROR_CODES)
|
||
known_vals: list[str] = []
|
||
for x in vals:
|
||
if x in allowed:
|
||
known_vals.append(x)
|
||
else:
|
||
unknown_retry_codes.append(x)
|
||
# dedupe while preserving order
|
||
seen: set[str] = set()
|
||
kept = []
|
||
for x in known_vals:
|
||
if x in seen:
|
||
continue
|
||
seen.add(x)
|
||
kept.append(x)
|
||
retry_codes = ",".join(kept)
|
||
else:
|
||
retry_codes = ",".join(DEFAULT_RETRYABLE_ERROR_CODES)
|
||
if retry_codes_strict_mode and unknown_retry_codes:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"code": "invalid_retryable_error_codes",
|
||
"message": "Unknown oclaw retryable error codes",
|
||
"unknown_retryable_error_codes": unknown_retry_codes,
|
||
"allowed_retryable_error_codes": list(ALL_ATTEMPT_ERROR_CODES),
|
||
},
|
||
)
|
||
if not retry_codes:
|
||
retry_codes = ",".join(DEFAULT_RETRYABLE_ERROR_CODES)
|
||
wc_workers = payload.get("wecom_longconn_workers", 2)
|
||
wc_in_q = payload.get("wecom_longconn_inbound_queue_maxsize", 200)
|
||
try:
|
||
_tn = int(tmsg)
|
||
tmsg_n = 0 if _tn == 0 else max(4096, min(_tn, 500_000))
|
||
except Exception:
|
||
tmsg_n = 0
|
||
try:
|
||
wc_workers_n = max(1, min(int(wc_workers), 8))
|
||
except Exception:
|
||
wc_workers_n = 2
|
||
try:
|
||
wc_in_q_n = max(20, min(int(wc_in_q), 5000))
|
||
except Exception:
|
||
wc_in_q_n = 200
|
||
# Canonical professional prefix.
|
||
store.set_setting("AIA_TURN_MAX_TOOL_WORKERS", str(tmw_n))
|
||
store.set_setting("AIA_TURN_MAX_TOOL_ROUNDS", str(tmr_n))
|
||
store.set_setting("AIA_TURN_MAX_CONTEXT_MESSAGES", str(tmc_n))
|
||
store.set_setting("AIA_SSE_QUEUE_MAXSIZE", str(sse_q_n))
|
||
store.set_setting("AIA_TOOL_LOG_MAX_CHARS", str(tl_cap_n))
|
||
store.set_setting("AIA_ENABLE_MCP_TOOLS", "1" if emcp else "0")
|
||
store.set_setting("AIA_ENABLE_PLUGIN_TOOLS", "1" if epl else "0")
|
||
os.environ["AIA_ENABLE_PLUGIN_TOOLS"] = "1" if epl else "0"
|
||
store.set_setting("AIA_ENABLE_RUN_COMMAND", "1" if erc else "0")
|
||
# Keep runtime gate aligned with Admin toggle immediately.
|
||
os.environ["AIA_ENABLE_RUN_COMMAND"] = "1" if erc else "0"
|
||
store.set_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED", "1" if tctx else "0")
|
||
store.set_setting("AIA_CHAT_SHOW_TTFT_DEBUG", "1" if ttft_debug else "0")
|
||
store.set_setting("AIA_SKILL_RUNTIME_ENABLED", "1" if skl_rt else "0")
|
||
store.set_setting("AIA_SKILL_AUTO_INSTALL_ENABLED", "1" if skl_ai else "0")
|
||
store.set_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS", str(tmsg_n))
|
||
store.set_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS", mcp_fs_roots)
|
||
store.set_setting("AIA_MCP_ENV_ALLOWLIST", mcp_env_allow)
|
||
store.set_setting("AIA_OCLAW_RETRYABLE_ERROR_CODES", retry_codes)
|
||
store.set_setting("AIA_OCLAW_RETRY_CODES_STRICT_MODE", "1" if retry_codes_strict_mode else "0")
|
||
store.set_setting("AIA_WECOM_LONGCONN_WORKERS", str(wc_workers_n))
|
||
store.set_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", str(wc_in_q_n))
|
||
store.set_setting("WECOM_LONGCONN_WORKERS", str(wc_workers_n))
|
||
store.set_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", str(wc_in_q_n))
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="tool_policy_update",
|
||
target_type="tool_policy",
|
||
target_id="AIA_TURN_MAX_TOOL_WORKERS,AIA_TURN_MAX_TOOL_ROUNDS,AIA_TURN_MAX_CONTEXT_MESSAGES,AIA_SSE_QUEUE_MAXSIZE,AIA_TOOL_LOG_MAX_CHARS,AIA_ENABLE_MCP_TOOLS,AIA_ENABLE_PLUGIN_TOOLS,AIA_ENABLE_RUN_COMMAND,AIA_TOOL_CONTEXT_TRUNCATE_ENABLED,AIA_CHAT_SHOW_TTFT_DEBUG,AIA_SKILL_RUNTIME_ENABLED,AIA_SKILL_AUTO_INSTALL_ENABLED,AIA_TOOL_LLM_MESSAGE_MAX_CHARS,AIA_MCP_FILESYSTEM_EXTRA_ROOTS,AIA_MCP_ENV_ALLOWLIST,AIA_OCLAW_RETRYABLE_ERROR_CODES,AIA_OCLAW_RETRY_CODES_STRICT_MODE,AIA_WECOM_LONGCONN_WORKERS,AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE",
|
||
status="ok",
|
||
detail={
|
||
"disable_tool_confirm": disable_confirm,
|
||
"enforced_retry_mode": retry_mode,
|
||
"tool_loop_state_machine": sm,
|
||
"tool_signature_budget": sig_budget_n,
|
||
"turn_max_tool_workers": tmw_n,
|
||
"turn_max_tool_rounds": tmr_n,
|
||
"turn_max_context_messages": tmc_n,
|
||
"turn_runner_impl": tri,
|
||
"manager_decision_mode": md,
|
||
"sse_queue_maxsize": sse_q_n,
|
||
"tool_log_max_chars": tl_cap_n,
|
||
"enable_mcp_tools": emcp,
|
||
"enable_plugin_tools": epl,
|
||
"enable_run_command": erc,
|
||
"tool_context_truncate_enabled": tctx,
|
||
"chat_show_ttft_debug": ttft_debug,
|
||
"skill_runtime_enabled": skl_rt,
|
||
"skill_auto_install_enabled": skl_ai,
|
||
"tool_llm_message_max_chars": tmsg_n,
|
||
"mcp_filesystem_extra_roots": mcp_fs_roots,
|
||
"mcp_env_allowlist": mcp_env_allow,
|
||
"oclaw_retryable_error_codes": retry_codes,
|
||
"oclaw_retry_codes_strict_mode": retry_codes_strict_mode,
|
||
"unknown_retryable_error_codes": unknown_retry_codes,
|
||
"wecom_longconn_workers": wc_workers_n,
|
||
"wecom_longconn_inbound_queue_maxsize": wc_in_q_n,
|
||
},
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"disable_tool_confirm": disable_confirm,
|
||
"enforced_retry_mode": retry_mode,
|
||
"tool_loop_state_machine": sm,
|
||
"tool_signature_budget": sig_budget_n,
|
||
"turn_max_tool_workers": tmw_n,
|
||
"turn_max_tool_rounds": tmr_n,
|
||
"turn_max_context_messages": tmc_n,
|
||
"turn_runner_impl": tri,
|
||
"manager_decision_mode": md,
|
||
"sse_queue_maxsize": sse_q_n,
|
||
"tool_log_max_chars": tl_cap_n,
|
||
"enable_mcp_tools": emcp,
|
||
"enable_plugin_tools": epl,
|
||
"enable_run_command": erc,
|
||
"tool_context_truncate_enabled": tctx,
|
||
"chat_show_ttft_debug": ttft_debug,
|
||
"skill_runtime_enabled": skl_rt,
|
||
"skill_auto_install_enabled": skl_ai,
|
||
"tool_llm_message_max_chars": tmsg_n,
|
||
"mcp_filesystem_extra_roots": mcp_fs_roots,
|
||
"mcp_env_allowlist": mcp_env_allow,
|
||
"oclaw_retryable_error_codes": retry_codes,
|
||
"oclaw_retry_codes_strict_mode": retry_codes_strict_mode,
|
||
"unknown_retryable_error_codes": unknown_retry_codes,
|
||
"wecom_longconn_workers": wc_workers_n,
|
||
"wecom_longconn_inbound_queue_maxsize": wc_in_q_n,
|
||
}
|
||
|
||
@router.get("/admin/api/mcp/servers")
|
||
def api_mcp_servers(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
rows = McpRegistry(store).list_servers(enabled_only=False)
|
||
health = {str(x.get("server_id") or ""): x for x in store.list_mcp_server_health()}
|
||
for r in rows:
|
||
sid = str(r.get("server_id") or "")
|
||
r["health"] = health.get(sid) or {}
|
||
r["tools"] = store.list_mcp_server_tools(server_id=sid)
|
||
r["cursor"] = registry_row_to_cursor_server(r)
|
||
return {"ok": True, "servers": rows}
|
||
|
||
@router.get("/admin/api/mcp/export")
|
||
def api_mcp_export(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
return {
|
||
"ok": True,
|
||
"local_path": str(mcp_migrated_json_path()),
|
||
"document": build_mcp_install_export_document(store),
|
||
}
|
||
|
||
@router.get("/admin/api/mcp/failures")
|
||
def api_mcp_failures(
|
||
limit: int = Query(default=20),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
return {"ok": True, "items": store.list_mcp_install_failure_summary(limit=limit)}
|
||
|
||
@router.get("/admin/api/mcp/usage")
|
||
def api_mcp_usage(
|
||
server_id: str | None = Query(default=None),
|
||
limit: int = Query(default=200),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
return {
|
||
"ok": True,
|
||
"summary": store.list_mcp_tool_usage_summary(limit=limit),
|
||
"calls": store.list_mcp_tool_call_logs(server_id=server_id, limit=limit),
|
||
}
|
||
|
||
@router.get("/admin/api/mcp/binding")
|
||
def api_mcp_binding(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
available = _ordered_mcp_roles()
|
||
servers = store.list_mcp_servers(enabled_only=False)
|
||
normalized_servers = [
|
||
{
|
||
"server_id": str(x.get("server_id") or ""),
|
||
"source_type": str(x.get("source_type") or ""),
|
||
"enabled": bool(x.get("enabled")),
|
||
}
|
||
for x in servers
|
||
if str(x.get("server_id") or "").strip()
|
||
]
|
||
mapping = _normalize_mcp_specialist_binding(
|
||
available_specialists=available,
|
||
servers=normalized_servers,
|
||
mapping=_load_mcp_specialist_binding(store),
|
||
)
|
||
return {"ok": True, "available_specialists": available, "servers": normalized_servers, "mapping": mapping}
|
||
|
||
@router.post("/admin/api/mcp/binding")
|
||
def api_mcp_binding_save(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
available = _ordered_mcp_roles()
|
||
servers = store.list_mcp_servers(enabled_only=False)
|
||
normalized_servers = [
|
||
{
|
||
"server_id": str(x.get("server_id") or ""),
|
||
"source_type": str(x.get("source_type") or ""),
|
||
"enabled": bool(x.get("enabled")),
|
||
}
|
||
for x in servers
|
||
if str(x.get("server_id") or "").strip()
|
||
]
|
||
mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {}
|
||
mapping = _normalize_mcp_specialist_binding(
|
||
available_specialists=available,
|
||
servers=normalized_servers,
|
||
mapping=mapping_raw,
|
||
)
|
||
store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False))
|
||
try:
|
||
from runtime.direct_loop import invalidate_tool_wire_cache
|
||
|
||
invalidate_tool_wire_cache(reason="mcp_binding_update")
|
||
except Exception:
|
||
pass
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_binding_update",
|
||
target_type="mcp_config",
|
||
target_id="specialist_server_binding",
|
||
status="ok",
|
||
detail={"mapping": mapping},
|
||
)
|
||
return {"ok": True, "available_specialists": available, "servers": normalized_servers, "mapping": mapping}
|
||
|
||
@router.get("/admin/api/mcp/specialists")
|
||
def api_mcp_specialists(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
available = _ordered_mcp_roles()
|
||
raw = str(store.get_setting("mcp_allowed_specialists") or "").strip() or "generalist,ops"
|
||
allowed = [x.strip().lower() for x in raw.split(",") if x.strip()]
|
||
allowed_set = set(allowed)
|
||
ordered = [x for x in available if x in allowed_set]
|
||
if not ordered:
|
||
ordered = ["generalist"] if "generalist" in available else (available[:1] if available else [])
|
||
return {"ok": True, "available_specialists": available, "allowed_specialists": ordered}
|
||
|
||
@router.post("/admin/api/mcp/specialists")
|
||
def api_mcp_specialists_save(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
available = _ordered_mcp_roles()
|
||
available_set = set(available)
|
||
raw = payload.get("allowed_specialists")
|
||
items = raw if isinstance(raw, list) else []
|
||
allowset = {str(x).strip().lower() for x in items if str(x).strip().lower() in available_set}
|
||
if not allowset:
|
||
defaults = {x for x in ("generalist", "ops") if x in available_set}
|
||
allowset = defaults if defaults else (set(available[:1]) if available else set())
|
||
ordered = [x for x in available if x in allowset]
|
||
store.set_setting("mcp_allowed_specialists", ",".join(ordered))
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_specialists_update",
|
||
target_type="mcp_config",
|
||
target_id="allowed_specialists",
|
||
status="ok",
|
||
detail={"allowed_specialists": ordered},
|
||
)
|
||
return {"ok": True, "available_specialists": available, "allowed_specialists": ordered}
|
||
|
||
@router.get("/admin/api/experts")
|
||
def api_experts_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||
return {"ok": True, "items": items}
|
||
|
||
@router.post("/admin/api/experts")
|
||
def api_experts_create(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
expert_id = normalize_expert_id(payload.get("id"))
|
||
display_name_en = str(payload.get("display_name_en") or "").strip()
|
||
display_name_zh = str(payload.get("display_name_zh") or "").strip()
|
||
role = str(payload.get("role") or "").strip().lower()
|
||
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
|
||
try:
|
||
files = _sanitize_expert_files_payload(files_raw)
|
||
created = create_expert(
|
||
expert_id=expert_id,
|
||
files=files,
|
||
display_name_en=display_name_en,
|
||
display_name_zh=display_name_zh,
|
||
role=role or None,
|
||
)
|
||
except ValueError as exc:
|
||
return {"ok": False, "error": str(exc)}
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="expert_create",
|
||
target_type="expert",
|
||
target_id=str(created.get("id") or ""),
|
||
status="ok",
|
||
detail={"id": str(created.get("id") or "")},
|
||
)
|
||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||
return {"ok": True, "created": str(created.get("id") or ""), "items": items}
|
||
|
||
@router.patch("/admin/api/experts/{expert_id}")
|
||
def api_experts_update(
|
||
expert_id: str,
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
|
||
eid = normalize_expert_id(expert_id)
|
||
display_name_en = str(payload.get("display_name_en") or "").strip()
|
||
display_name_zh = str(payload.get("display_name_zh") or "").strip()
|
||
role = str(payload.get("role") or "").strip().lower()
|
||
try:
|
||
files = _sanitize_expert_files_payload(files_raw)
|
||
update_expert_files(expert_id=eid, files=files)
|
||
update_expert_meta(
|
||
expert_id=eid,
|
||
display_name_en=display_name_en,
|
||
display_name_zh=display_name_zh,
|
||
role=role or None,
|
||
)
|
||
except ValueError as exc:
|
||
return {"ok": False, "error": str(exc)}
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="expert_update",
|
||
target_type="expert",
|
||
target_id=eid,
|
||
status="ok",
|
||
detail={"id": eid, "updated_files": sorted(list(files.keys()))[:10]},
|
||
)
|
||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||
return {"ok": True, "updated": eid, "items": items}
|
||
|
||
@router.delete("/admin/api/experts/{expert_id}")
|
||
def api_experts_delete(expert_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
eid = normalize_expert_id(expert_id)
|
||
try:
|
||
delete_expert(eid)
|
||
except ValueError as exc:
|
||
return {"ok": False, "error": str(exc)}
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="expert_delete",
|
||
target_type="expert",
|
||
target_id=eid,
|
||
status="ok",
|
||
detail={"id": eid},
|
||
)
|
||
items = [_serialize_expert_row(x) for x in list_experts()]
|
||
return {"ok": True, "deleted": eid, "items": items}
|
||
|
||
@router.post("/admin/api/mcp/install")
|
||
def api_mcp_install(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
dry_run = bool(payload.get("dry_run", False))
|
||
try:
|
||
items = parse_cursor_mcp_document(payload)
|
||
except ValueError as exc:
|
||
return {"ok": False, "error": str(exc)}
|
||
results: list[dict[str, Any]] = []
|
||
all_ok = True
|
||
for item in items:
|
||
entry_args = _expand_mcp_entry_args(
|
||
item.get("entry_args") if isinstance(item.get("entry_args"), list) else []
|
||
)
|
||
manifest = McpServerManifest(
|
||
server_id=str(item.get("server_id") or ""),
|
||
source_type=str(item.get("source_type") or "local"),
|
||
source_ref=str(item.get("source_ref") or ""),
|
||
version=str(item.get("version") or "").strip(),
|
||
entry_command=str(item.get("entry_command") or "").strip(),
|
||
entry_args=entry_args,
|
||
env_schema=item.get("env_schema") if isinstance(item.get("env_schema"), dict) else {},
|
||
permissions=[str(x) for x in (item.get("required_permissions") or [])],
|
||
risk_level=str(item.get("risk_level") or "high"),
|
||
enabled=bool(item.get("enabled", True)),
|
||
timeout_s=float(item.get("timeout_s") or 30.0),
|
||
)
|
||
install = install_mcp_server(manifest, dry_run=dry_run)
|
||
store.upsert_mcp_server(
|
||
server_id=manifest.server_id,
|
||
source_type=manifest.source_type,
|
||
source_ref=manifest.source_ref,
|
||
version=manifest.version,
|
||
entry_command=manifest.entry_command,
|
||
entry_args=manifest.entry_args,
|
||
env_schema=manifest.env_schema,
|
||
required_permissions=manifest.permissions,
|
||
risk_level=manifest.risk_level,
|
||
timeout_s=manifest.timeout_s,
|
||
enabled=manifest.enabled if install.ok else False,
|
||
)
|
||
store.add_mcp_installation_log(
|
||
server_id=manifest.server_id,
|
||
status="ok" if install.ok else "error",
|
||
error_code=install.error_code,
|
||
detail={"error": install.error, **(install.details or {})},
|
||
install_command=install.install_command,
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_install",
|
||
target_type="mcp_server",
|
||
target_id=manifest.server_id,
|
||
status="ok" if install.ok else "error",
|
||
detail={
|
||
"source_type": manifest.source_type,
|
||
"source_ref": manifest.source_ref,
|
||
"error_code": install.error_code,
|
||
"cursor": True,
|
||
},
|
||
)
|
||
row_out = {
|
||
"ok": bool(install.ok),
|
||
"server_id": manifest.server_id,
|
||
"install_command": install.install_command,
|
||
"error_code": install.error_code,
|
||
"error": install.error,
|
||
"cursor": registry_row_to_cursor_server(
|
||
{
|
||
"server_id": manifest.server_id,
|
||
"entry_command": manifest.entry_command,
|
||
"entry_args": manifest.entry_args,
|
||
"env_schema": manifest.env_schema,
|
||
}
|
||
),
|
||
}
|
||
if not install.ok:
|
||
all_ok = False
|
||
results.append(row_out)
|
||
if all_ok:
|
||
persist_mcp_migrated_file(store)
|
||
return {
|
||
"ok": all_ok,
|
||
"results": results,
|
||
"installed": [x.get("server_id") for x in results if x.get("ok")],
|
||
"mcp_migrated_saved": str(mcp_migrated_json_path()) if all_ok else "",
|
||
}
|
||
|
||
@router.post("/admin/api/mcp/config")
|
||
def api_mcp_config(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
if not server_id:
|
||
return {"ok": False, "error": "server_id_required"}
|
||
rows = store.list_mcp_servers(enabled_only=False)
|
||
existing = next((x for x in rows if str(x.get("server_id") or "") == server_id), None)
|
||
if not existing:
|
||
return {"ok": False, "error": "server_not_found"}
|
||
try:
|
||
fields = config_payload_to_upsert_fields(payload, existing=existing)
|
||
except ValueError as exc:
|
||
return {"ok": False, "error": str(exc)}
|
||
entry_args = _expand_mcp_entry_args(
|
||
fields.get("entry_args") if isinstance(fields.get("entry_args"), list) else []
|
||
)
|
||
reinstall = bool(payload.get("reinstall", False))
|
||
dry_run = bool(payload.get("dry_run", False))
|
||
manifest = McpServerManifest(
|
||
server_id=str(fields.get("server_id") or server_id),
|
||
source_type=str(fields.get("source_type") or "local"),
|
||
source_ref=str(fields.get("source_ref") or server_id),
|
||
version=str(fields.get("version") or "").strip(),
|
||
entry_command=str(fields.get("entry_command") or "").strip(),
|
||
entry_args=entry_args,
|
||
env_schema=fields.get("env_schema") if isinstance(fields.get("env_schema"), dict) else {},
|
||
permissions=[str(x) for x in (fields.get("required_permissions") or [])],
|
||
risk_level=str(fields.get("risk_level") or "high"),
|
||
enabled=bool(fields.get("enabled", True)),
|
||
timeout_s=float(fields.get("timeout_s") or 30.0),
|
||
)
|
||
install_ok = True
|
||
install_command = ""
|
||
error_code = ""
|
||
error = ""
|
||
if reinstall:
|
||
install = install_mcp_server(manifest, dry_run=dry_run)
|
||
install_ok = bool(install.ok)
|
||
install_command = install.install_command
|
||
error_code = install.error_code
|
||
error = install.error
|
||
store.add_mcp_installation_log(
|
||
server_id=manifest.server_id,
|
||
status="ok" if install.ok else "error",
|
||
error_code=install.error_code,
|
||
detail={"error": install.error, **(install.details or {}), "config_reinstall": True},
|
||
install_command=install.install_command,
|
||
)
|
||
store.upsert_mcp_server(
|
||
server_id=manifest.server_id,
|
||
source_type=manifest.source_type,
|
||
source_ref=manifest.source_ref,
|
||
version=manifest.version,
|
||
entry_command=manifest.entry_command,
|
||
entry_args=manifest.entry_args,
|
||
env_schema=manifest.env_schema,
|
||
required_permissions=manifest.permissions,
|
||
risk_level=manifest.risk_level,
|
||
timeout_s=manifest.timeout_s,
|
||
enabled=manifest.enabled if install_ok else False,
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_config_update",
|
||
target_type="mcp_server",
|
||
target_id=manifest.server_id,
|
||
status="ok" if install_ok else "error",
|
||
detail={"reinstall": reinstall, "error_code": error_code},
|
||
)
|
||
if not install_ok:
|
||
return {
|
||
"ok": False,
|
||
"server_id": manifest.server_id,
|
||
"error_code": error_code,
|
||
"error": error,
|
||
}
|
||
persist_mcp_migrated_file(store)
|
||
cursor = registry_row_to_cursor_server(
|
||
{
|
||
"server_id": manifest.server_id,
|
||
"entry_command": manifest.entry_command,
|
||
"entry_args": manifest.entry_args,
|
||
"env_schema": manifest.env_schema,
|
||
}
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"server_id": manifest.server_id,
|
||
"install_command": install_command,
|
||
"cursor": cursor,
|
||
"mcp_migrated_saved": str(mcp_migrated_json_path()),
|
||
}
|
||
|
||
@router.post("/admin/api/mcp/reinstall")
|
||
def api_mcp_reinstall(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
if not server_id:
|
||
return {"ok": False, "error": "server_id_required"}
|
||
rows = store.list_mcp_servers(enabled_only=False)
|
||
row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None)
|
||
if not row:
|
||
return {"ok": False, "error": "server_not_found"}
|
||
manifest = McpServerManifest(
|
||
server_id=str(row.get("server_id") or ""),
|
||
source_type=str(row.get("source_type") or ""),
|
||
source_ref=str(row.get("source_ref") or ""),
|
||
version=str(row.get("version") or ""),
|
||
entry_command=str(row.get("entry_command") or ""),
|
||
entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()],
|
||
env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {},
|
||
permissions=[str(x) for x in (row.get("required_permissions") or [])],
|
||
risk_level=str(row.get("risk_level") or "high"),
|
||
enabled=bool(row.get("enabled")),
|
||
timeout_s=float(row.get("timeout_s") or 30.0),
|
||
)
|
||
dry_run = bool(payload.get("dry_run", False))
|
||
install = install_mcp_server(manifest, dry_run=dry_run)
|
||
store.upsert_mcp_server(
|
||
server_id=manifest.server_id,
|
||
source_type=manifest.source_type,
|
||
source_ref=manifest.source_ref,
|
||
version=manifest.version,
|
||
entry_command=manifest.entry_command,
|
||
entry_args=manifest.entry_args,
|
||
env_schema=manifest.env_schema,
|
||
required_permissions=manifest.permissions,
|
||
risk_level=manifest.risk_level,
|
||
timeout_s=manifest.timeout_s,
|
||
enabled=manifest.enabled if install.ok else False,
|
||
)
|
||
store.add_mcp_installation_log(
|
||
server_id=manifest.server_id,
|
||
status="ok" if install.ok else "error",
|
||
error_code=install.error_code,
|
||
detail={"error": install.error, **(install.details or {}), "reinstall": True},
|
||
install_command=install.install_command,
|
||
)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_reinstall",
|
||
target_type="mcp_server",
|
||
target_id=manifest.server_id,
|
||
status="ok" if install.ok else "error",
|
||
detail={"error_code": install.error_code},
|
||
)
|
||
if not install.ok:
|
||
return {"ok": False, "server_id": manifest.server_id, "error_code": install.error_code, "error": install.error}
|
||
persist_mcp_migrated_file(store)
|
||
return {
|
||
"ok": True,
|
||
"server_id": manifest.server_id,
|
||
"install_command": install.install_command,
|
||
"mcp_migrated_saved": str(mcp_migrated_json_path()),
|
||
}
|
||
|
||
@router.post("/admin/api/mcp/update")
|
||
def api_mcp_update(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
enabled_only = bool(payload.get("enabled_only", True))
|
||
dry_run = bool(payload.get("dry_run", False))
|
||
sync_tools = bool(payload.get("sync_tools", True))
|
||
version_override = str(payload.get("version") or "").strip()
|
||
update_to_latest = bool(payload.get("update_to_latest", True))
|
||
|
||
rows = store.list_mcp_servers(enabled_only=enabled_only if not server_id else False)
|
||
if server_id:
|
||
rows = [x for x in rows if str(x.get("server_id") or "").strip() == server_id]
|
||
if not rows:
|
||
return {"ok": False, "error": "server_not_found"}
|
||
|
||
apply_gateway_mcp_env_to_os()
|
||
out: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
sid = str(row.get("server_id") or "").strip()
|
||
req_version = version_override
|
||
if not req_version:
|
||
req_version = "" if update_to_latest else str(row.get("version") or "").strip()
|
||
manifest = McpServerManifest(
|
||
server_id=sid,
|
||
source_type=str(row.get("source_type") or ""),
|
||
source_ref=str(row.get("source_ref") or ""),
|
||
version=req_version,
|
||
entry_command=str(row.get("entry_command") or ""),
|
||
entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()],
|
||
env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {},
|
||
permissions=[str(x) for x in (row.get("required_permissions") or [])],
|
||
risk_level=str(row.get("risk_level") or "high"),
|
||
enabled=bool(row.get("enabled")),
|
||
timeout_s=float(row.get("timeout_s") or 30.0),
|
||
)
|
||
install = install_mcp_server(manifest, dry_run=dry_run)
|
||
store.upsert_mcp_server(
|
||
server_id=manifest.server_id,
|
||
source_type=manifest.source_type,
|
||
source_ref=manifest.source_ref,
|
||
version=manifest.version,
|
||
entry_command=manifest.entry_command,
|
||
entry_args=manifest.entry_args,
|
||
env_schema=manifest.env_schema,
|
||
required_permissions=manifest.permissions,
|
||
risk_level=manifest.risk_level,
|
||
timeout_s=manifest.timeout_s,
|
||
enabled=manifest.enabled if install.ok else False,
|
||
)
|
||
item: dict[str, Any] = {
|
||
"server_id": manifest.server_id,
|
||
"ok": bool(install.ok),
|
||
"error_code": str(install.error_code or ""),
|
||
"error": str(install.error or ""),
|
||
"install_command": str(install.install_command or ""),
|
||
"requested_version": req_version,
|
||
"tools_synced": 0,
|
||
}
|
||
if install.ok and sync_tools and (not dry_run):
|
||
latest_row = next(
|
||
(x for x in store.list_mcp_servers(enabled_only=False) if str(x.get("server_id") or "") == sid),
|
||
None,
|
||
)
|
||
if isinstance(latest_row, dict):
|
||
hs = _mcp_health_and_sync_one(store, latest_row)
|
||
if isinstance(hs, dict):
|
||
item["tools_synced"] = int(hs.get("tools_synced") or 0)
|
||
if not bool(hs.get("ok")):
|
||
item["ok"] = False
|
||
item["error_code"] = str(hs.get("error_code") or "mcp_sync_failed")
|
||
item["error"] = str(hs.get("error") or "sync_failed")
|
||
store.add_mcp_installation_log(
|
||
server_id=manifest.server_id,
|
||
status="ok" if item["ok"] else "error",
|
||
error_code=str(item["error_code"] or None),
|
||
detail={
|
||
"error": item["error"],
|
||
"update": True,
|
||
"requested_version": req_version,
|
||
"tools_synced": int(item.get("tools_synced") or 0),
|
||
},
|
||
install_command=install.install_command,
|
||
)
|
||
out.append(item)
|
||
|
||
ok_count = len([x for x in out if bool(x.get("ok"))])
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_update" if server_id else "mcp_update_batch",
|
||
target_type="mcp_server",
|
||
target_id=server_id or "batch",
|
||
status="ok" if ok_count == len(out) else "error",
|
||
detail={
|
||
"enabled_only": enabled_only,
|
||
"dry_run": dry_run,
|
||
"sync_tools": sync_tools,
|
||
"update_to_latest": update_to_latest,
|
||
"requested_version": version_override,
|
||
"ok_count": ok_count,
|
||
"error_count": len(out) - ok_count,
|
||
},
|
||
)
|
||
if ok_count > 0:
|
||
persist_mcp_migrated_file(store)
|
||
return {
|
||
"ok": ok_count == len(out),
|
||
"total": len(out),
|
||
"ok_count": ok_count,
|
||
"error_count": len(out) - ok_count,
|
||
"items": out,
|
||
"mcp_migrated_saved": str(mcp_migrated_json_path()),
|
||
}
|
||
|
||
@router.post("/admin/api/mcp/uninstall")
|
||
def api_mcp_uninstall(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
remove_record = bool(payload.get("remove_record", True))
|
||
if not server_id:
|
||
return {"ok": False, "error": "server_id_required"}
|
||
rows = store.list_mcp_servers(enabled_only=False)
|
||
row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None)
|
||
if not row:
|
||
return {"ok": False, "error": "server_not_found"}
|
||
manifest = McpServerManifest(
|
||
server_id=str(row.get("server_id") or ""),
|
||
source_type=str(row.get("source_type") or ""),
|
||
source_ref=str(row.get("source_ref") or ""),
|
||
version=str(row.get("version") or ""),
|
||
entry_command=str(row.get("entry_command") or ""),
|
||
entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()],
|
||
env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {},
|
||
permissions=[str(x) for x in (row.get("required_permissions") or [])],
|
||
risk_level=str(row.get("risk_level") or "high"),
|
||
enabled=False,
|
||
timeout_s=float(row.get("timeout_s") or 30.0),
|
||
)
|
||
dry_run = bool(payload.get("dry_run", False))
|
||
res = uninstall_mcp_server(manifest, dry_run=dry_run)
|
||
store.add_mcp_installation_log(
|
||
server_id=manifest.server_id,
|
||
status="ok" if res.ok else "error",
|
||
error_code=res.error_code,
|
||
detail={"error": res.error, **(res.details or {}), "uninstall": True},
|
||
install_command=res.install_command,
|
||
)
|
||
deleted = {"registry": 0, "tools": 0, "health": 0, "install_logs": 0}
|
||
if res.ok and remove_record and not dry_run:
|
||
deleted = store.delete_mcp_server(server_id=manifest.server_id)
|
||
mapping = _load_mcp_specialist_binding(store)
|
||
for sp, sids in list(mapping.items()):
|
||
mapping[sp] = [sid for sid in sids if sid != manifest.server_id]
|
||
store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False))
|
||
try:
|
||
from runtime.direct_loop import invalidate_tool_wire_cache
|
||
|
||
invalidate_tool_wire_cache(reason=f"mcp_uninstall_binding:{manifest.server_id}")
|
||
except Exception:
|
||
pass
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_uninstall",
|
||
target_type="mcp_server",
|
||
target_id=manifest.server_id,
|
||
status="ok" if res.ok else "error",
|
||
detail={"error_code": res.error_code, "remove_record": remove_record, "deleted": deleted},
|
||
)
|
||
if not res.ok:
|
||
return {"ok": False, "server_id": manifest.server_id, "error_code": res.error_code, "error": res.error}
|
||
persist_mcp_migrated_file(store)
|
||
return {"ok": True, "server_id": manifest.server_id, "uninstall_command": res.install_command, "deleted": deleted}
|
||
|
||
@router.post("/admin/api/mcp/delete")
|
||
def api_mcp_delete(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
if not server_id:
|
||
return {"ok": False, "error": "server_id_required"}
|
||
deleted = store.delete_mcp_server(server_id=server_id)
|
||
mapping = _load_mcp_specialist_binding(store)
|
||
for sp, sids in list(mapping.items()):
|
||
mapping[sp] = [sid for sid in sids if sid != server_id]
|
||
store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False))
|
||
try:
|
||
from runtime.direct_loop import invalidate_tool_wire_cache
|
||
|
||
invalidate_tool_wire_cache(reason=f"mcp_delete_binding:{server_id}")
|
||
except Exception:
|
||
pass
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_delete",
|
||
target_type="mcp_server",
|
||
target_id=server_id,
|
||
status="ok" if any(int(v or 0) > 0 for v in deleted.values()) else "miss",
|
||
detail={"deleted": deleted},
|
||
)
|
||
if any(int(v or 0) > 0 for v in deleted.values()):
|
||
persist_mcp_migrated_file(store)
|
||
return {"ok": True, "server_id": server_id, "deleted": deleted}
|
||
|
||
@router.post("/admin/api/mcp/preflight")
|
||
def api_mcp_preflight(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
source_type = str(payload.get("source_type") or "").strip().lower()
|
||
source_ref = str(payload.get("source_ref") or "").strip()
|
||
if source_type not in {"github", "npm", "pypi", "local"} or not source_ref:
|
||
return {"ok": False, "error_code": "mcp_invalid_source", "error": "invalid_source"}
|
||
manifest = McpServerManifest(
|
||
server_id=_safe_server_id(str(payload.get("server_id") or source_ref)),
|
||
source_type=source_type,
|
||
source_ref=source_ref,
|
||
version=str(payload.get("version") or "").strip(),
|
||
entry_command=str(payload.get("entry_command") or "").strip(),
|
||
entry_args=_expand_mcp_entry_args(payload.get("entry_args") if isinstance(payload.get("entry_args"), list) else []),
|
||
env_schema=payload.get("env_schema") if isinstance(payload.get("env_schema"), dict) else {},
|
||
permissions=[str(x) for x in (payload.get("required_permissions") or [])],
|
||
risk_level=str(payload.get("risk_level") or "high"),
|
||
enabled=bool(payload.get("enabled", True)),
|
||
timeout_s=float(payload.get("timeout_s") or 30.0),
|
||
)
|
||
res = preflight_mcp_server(manifest)
|
||
return {"ok": bool(res.get("ok")), **res}
|
||
|
||
@router.post("/admin/api/mcp/toggle")
|
||
def api_mcp_toggle(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
enabled = bool(payload.get("enabled"))
|
||
if not server_id:
|
||
return {"ok": False, "error": "server_id_required"}
|
||
n = store.set_mcp_server_enabled(server_id=server_id, enabled=enabled)
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_toggle",
|
||
target_type="mcp_server",
|
||
target_id=server_id,
|
||
status="ok" if n > 0 else "miss",
|
||
detail={"enabled": enabled},
|
||
)
|
||
return {"ok": True, "updated": n}
|
||
|
||
@router.post("/admin/api/mcp/healthcheck")
|
||
def api_mcp_healthcheck(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
apply_gateway_mcp_env_to_os()
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
if not server_id:
|
||
return {"ok": False, "error": "server_id_required"}
|
||
rows = store.list_mcp_servers(enabled_only=False)
|
||
row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None)
|
||
if not row:
|
||
return {"ok": False, "error": "server_not_found"}
|
||
cmd = str(row.get("entry_command") or "").strip()
|
||
args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()]
|
||
if not cmd:
|
||
return {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"}
|
||
if _is_bailian_webparser_remote(entry_command=cmd, entry_args=args):
|
||
detail = {"ok": True, "status": "ok", "compat_mode": "bailian_webparser", "tools_count": 1}
|
||
store.set_mcp_server_health(server_id=server_id, status="ok", detail=detail)
|
||
return {"ok": True, "response": detail}
|
||
rt = mcp_runtime_for_row(row, store=store)
|
||
try:
|
||
response = rt.health()
|
||
ok = bool(response.get("ok"))
|
||
store.set_mcp_server_health(server_id=server_id, status="ok" if ok else "error", detail=response)
|
||
if ok:
|
||
return {"ok": True, "response": response}
|
||
return {
|
||
"ok": False,
|
||
"error_code": str(response.get("error_code") or "mcp_healthcheck_failed"),
|
||
"error": str(response.get("error") or "healthcheck_failed"),
|
||
"response": response,
|
||
}
|
||
except Exception as exc:
|
||
store.set_mcp_server_health(
|
||
server_id=server_id,
|
||
status="error",
|
||
detail={"error_code": "mcp_healthcheck_failed", "error": f"{type(exc).__name__}: {exc}"},
|
||
)
|
||
return {"ok": False, "error_code": "mcp_healthcheck_failed", "error": f"{type(exc).__name__}: {exc}"}
|
||
finally:
|
||
rt.stop()
|
||
|
||
@router.post("/admin/api/mcp/tools/sync")
|
||
def api_mcp_tools_sync(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
apply_gateway_mcp_env_to_os()
|
||
server_id = str(payload.get("server_id") or "").strip()
|
||
if not server_id:
|
||
return {"ok": False, "error": "server_id_required"}
|
||
rows = store.list_mcp_servers(enabled_only=False)
|
||
row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None)
|
||
if not row:
|
||
return {"ok": False, "error": "server_not_found"}
|
||
cmd = str(row.get("entry_command") or "").strip()
|
||
args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()]
|
||
if not cmd:
|
||
return {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"}
|
||
if _is_bailian_webparser_remote(entry_command=cmd, entry_args=args):
|
||
norm = _bailian_webparser_virtual_tools()
|
||
store.replace_mcp_server_tools(server_id=server_id, tools=norm)
|
||
store.set_mcp_server_health(
|
||
server_id=server_id,
|
||
status="ok",
|
||
detail={"synced_tools": len(norm), "compat_mode": "bailian_webparser"},
|
||
)
|
||
try:
|
||
from runtime.direct_loop import invalidate_tool_wire_cache
|
||
|
||
invalidate_tool_wire_cache(reason=f"mcp_admin_sync:{server_id}")
|
||
except Exception:
|
||
pass
|
||
prewarm = _spawn_async_runtime_prewarm(reason=f"mcp_admin_sync:{server_id}")
|
||
return {
|
||
"ok": True,
|
||
"server_id": server_id,
|
||
"synced_tools": len(norm),
|
||
"tool_names": [str(t.get("tool_name") or "") for t in norm if str(t.get("tool_name") or "").strip()],
|
||
"compat_mode": "bailian_webparser",
|
||
"prewarm": prewarm,
|
||
}
|
||
rt = mcp_runtime_for_row(row, store=store)
|
||
try:
|
||
response = rt.tools_list()
|
||
items = response.get("tools") if isinstance(response, dict) else None
|
||
if not isinstance(items, list):
|
||
return {"ok": False, "error_code": "mcp_tools_list_invalid", "error": "tools_list_invalid"}
|
||
norm = []
|
||
for it in items:
|
||
if not isinstance(it, dict):
|
||
continue
|
||
tool_name = str(it.get("tool_name") or it.get("name") or "").strip()
|
||
if not tool_name:
|
||
continue
|
||
norm.append(
|
||
{
|
||
"tool_name": tool_name,
|
||
"description": str(it.get("description") or ""),
|
||
"parameters": it.get("parameters") if isinstance(it.get("parameters"), dict) else {},
|
||
}
|
||
)
|
||
store.replace_mcp_server_tools(server_id=server_id, tools=norm)
|
||
store.set_mcp_server_health(server_id=server_id, status="ok", detail={"synced_tools": len(norm)})
|
||
try:
|
||
from runtime.direct_loop import invalidate_tool_wire_cache
|
||
|
||
invalidate_tool_wire_cache(reason=f"mcp_admin_sync:{server_id}")
|
||
except Exception:
|
||
pass
|
||
prewarm = _spawn_async_runtime_prewarm(reason=f"mcp_admin_sync:{server_id}")
|
||
return {
|
||
"ok": True,
|
||
"server_id": server_id,
|
||
"synced_tools": len(norm),
|
||
"tool_names": [str(t.get("tool_name") or "") for t in norm if str(t.get("tool_name") or "").strip()],
|
||
"prewarm": prewarm,
|
||
}
|
||
finally:
|
||
rt.stop()
|
||
|
||
@router.post("/admin/api/mcp/check-all")
|
||
def api_mcp_check_all(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
apply_gateway_mcp_env_to_os()
|
||
enabled_only = bool(payload.get("enabled_only", True))
|
||
rows = store.list_mcp_servers(enabled_only=enabled_only)
|
||
out: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
item = _mcp_health_and_sync_one(store, row)
|
||
if item is not None:
|
||
out.append(item)
|
||
ok_count = len([x for x in out if bool(x.get("ok"))])
|
||
return {"ok": True, "total": len(out), "ok_count": ok_count, "error_count": len(out) - ok_count, "items": out}
|
||
|
||
@router.post("/admin/api/mcp/check-updates")
|
||
def api_mcp_check_updates(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
enabled_only = bool(payload.get("enabled_only", True))
|
||
rows = store.list_mcp_servers(enabled_only=enabled_only)
|
||
items = [_check_mcp_update_row(row) for row in rows]
|
||
update_count = len([x for x in items if bool(x.get("has_update"))])
|
||
return {
|
||
"ok": True,
|
||
"total": len(items),
|
||
"update_count": update_count,
|
||
"up_to_date_count": len(items) - update_count,
|
||
"items": items,
|
||
}
|
||
|
||
@router.post("/admin/api/mcp/repair-weak")
|
||
def api_mcp_repair_weak(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
"""
|
||
For enabled MCP rows whose persisted health is not ``ok`` and/or cached tool list is empty,
|
||
run the same health + tools/list + replace flow as ``check-all`` (only those servers).
|
||
"""
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
apply_gateway_mcp_env_to_os()
|
||
enabled_only = bool(payload.get("enabled_only", True))
|
||
rows = store.list_mcp_servers(enabled_only=enabled_only)
|
||
health_by_sid = {str(x.get("server_id") or ""): x for x in store.list_mcp_server_health()}
|
||
targets: list[dict[str, Any]] = []
|
||
skipped_healthy = 0
|
||
for row in rows:
|
||
sid = str(row.get("server_id") or "").strip()
|
||
if not sid:
|
||
continue
|
||
h = health_by_sid.get(sid) or {}
|
||
st = str(h.get("status") or "").strip().lower()
|
||
n_tools = len(store.list_mcp_server_tools(server_id=sid))
|
||
if st == "ok" and n_tools > 0:
|
||
skipped_healthy += 1
|
||
continue
|
||
targets.append(row)
|
||
out: list[dict[str, Any]] = []
|
||
for row in targets:
|
||
item = _mcp_health_and_sync_one(store, row)
|
||
if item is not None:
|
||
out.append(item)
|
||
ok_count = len([x for x in out if bool(x.get("ok"))])
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_repair_weak",
|
||
target_type="mcp_server",
|
||
target_id="batch",
|
||
status="ok",
|
||
detail={
|
||
"selected": len(targets),
|
||
"skipped_healthy": skipped_healthy,
|
||
"ok_count": ok_count,
|
||
"error_count": len(out) - ok_count,
|
||
},
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"selected": len(targets),
|
||
"skipped_healthy": skipped_healthy,
|
||
"total": len(out),
|
||
"ok_count": ok_count,
|
||
"error_count": len(out) - ok_count,
|
||
"items": out,
|
||
}
|
||
|
||
@router.post("/admin/api/mcp/e2e-check")
|
||
def api_mcp_e2e_check(
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
apply_gateway_mcp_env_to_os()
|
||
sess = store.create_session("mcp-e2e-check")
|
||
session_id = str(sess.id)
|
||
try:
|
||
store.add_message(session_id=session_id, role="user", content="mcp e2e check")
|
||
reg = default_registry(expert="generalist+workspace+productivity", specialist="generalist")
|
||
runtime = ToolExecutor()
|
||
names = {t.name for t in reg.list()}
|
||
plans, skipped_servers = build_mcp_e2e_probe_plans(reg, workspace_root=str(PROJECT_ROOT.resolve()))
|
||
out: list[dict[str, Any]] = []
|
||
for server_id, full_name, args in plans:
|
||
row = {
|
||
"server_id": server_id,
|
||
"tool_name": full_name,
|
||
"in_registry": full_name in names,
|
||
"ok": False,
|
||
"duration_ms": 0,
|
||
"error": "",
|
||
}
|
||
if not bool(row["in_registry"]):
|
||
row["error"] = "tool_not_in_registry"
|
||
out.append(row)
|
||
continue
|
||
call = LLMToolCall(id=f"call_{server_id}", name=full_name, arguments=args)
|
||
tool_ctx = ToolExecutionContext(
|
||
store=store,
|
||
tools=reg,
|
||
session_id=session_id,
|
||
lang="zh",
|
||
user_text=f"e2e check {full_name}",
|
||
specialist="generalist",
|
||
task_kind="mcp_e2e",
|
||
policy_engine=None,
|
||
trace_id="mcp-e2e",
|
||
parent_span_id="root",
|
||
)
|
||
_tool_msgs, result_by_id = runtime.execute_tool_uses(
|
||
ctx=tool_ctx,
|
||
assistant_msg_id=0,
|
||
tool_uses=[call],
|
||
on_tool_ui=None,
|
||
should_stop=None,
|
||
)
|
||
hit = result_by_id.get(call.id)
|
||
if hit:
|
||
result, dur = hit
|
||
row["duration_ms"] = int(dur or 0)
|
||
row["ok"] = bool((result or {}).get("ok"))
|
||
row["error"] = str((result or {}).get("error") or (result or {}).get("error_code") or "")
|
||
else:
|
||
row["error"] = "no_result"
|
||
out.append(row)
|
||
for sid in skipped_servers:
|
||
out.append(
|
||
{
|
||
"server_id": sid,
|
||
"tool_name": "",
|
||
"in_registry": True,
|
||
"ok": False,
|
||
"duration_ms": 0,
|
||
"error": "e2e_no_probe_candidate",
|
||
}
|
||
)
|
||
ok_count = len([x for x in out if bool(x.get("ok"))])
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="mcp_e2e_check",
|
||
target_type="mcp_server",
|
||
target_id="all",
|
||
status="ok",
|
||
detail={"session_id": session_id, "total": len(out), "ok_count": ok_count},
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"session_id": session_id,
|
||
"total": len(out),
|
||
"ok_count": ok_count,
|
||
"error_count": len(out) - ok_count,
|
||
"items": out,
|
||
}
|
||
except Exception as e:
|
||
return {"ok": False, "session_id": session_id, "error": f"{type(e).__name__}: {e}"}
|
||
|
||
@router.get("/admin/api/memory/hits")
|
||
def api_memory_hits(
|
||
tenant_id: str | None = Query(default=None),
|
||
user_id: str | None = Query(default=None),
|
||
limit: int = Query(default=100),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
if tenant_id:
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
rows = store.list_memory_hit_logs(tenant_id=tenant_id, user_id=user_id, limit=limit)
|
||
return {"ok": True, "hits": rows}
|
||
|
||
@router.get("/admin/api/memory/stats")
|
||
def api_memory_stats(
|
||
tenant_id: str | None = Query(default=None),
|
||
user_id: str | None = Query(default=None),
|
||
limit: int = Query(default=300),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
if tenant_id:
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
hits = store.list_memory_hit_logs(tenant_id=tenant_id, user_id=user_id, limit=limit)
|
||
items = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=500, offset=0)
|
||
hit_count = len(hits)
|
||
avg_score = (sum(float(x.get("score") or 0.0) for x in hits) / hit_count) if hit_count else 0.0
|
||
source_count: dict[str, int] = {}
|
||
for h in hits:
|
||
src = str(h.get("source") or "unknown")
|
||
source_count[src] = source_count.get(src, 0) + 1
|
||
top_sources = sorted(source_count.items(), key=lambda x: x[1], reverse=True)[:5]
|
||
return {
|
||
"ok": True,
|
||
"stats": {
|
||
"hit_count": hit_count,
|
||
"avg_score": avg_score,
|
||
"item_count": len(items),
|
||
"top_sources": [{"source": k, "count": v} for k, v in top_sources],
|
||
},
|
||
}
|
||
|
||
@router.get("/admin/api/memory/items")
|
||
def api_memory_items(
|
||
tenant_id: str | None = Query(default=None),
|
||
user_id: str | None = Query(default=None),
|
||
limit: int = Query(default=100),
|
||
offset: int = Query(default=0),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
if tenant_id:
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
rows = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset)
|
||
runtime = read_vector_memory_runtime(store)
|
||
return {"ok": True, "items": rows, "runtime": runtime.__dict__}
|
||
|
||
@router.post("/admin/api/memory/delete")
|
||
def api_memory_delete(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:memory:write")
|
||
memory_id = str(payload.get("memory_id") or "").strip()
|
||
if not memory_id:
|
||
return {"ok": False, "error": "memory_id is required"}
|
||
deleted = store.delete_memory_item(memory_id=memory_id)
|
||
return {"ok": True, "deleted": deleted}
|
||
|
||
@router.post("/admin/api/memory/reindex")
|
||
def api_memory_reindex(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:memory:write")
|
||
tenant_id = str(payload.get("tenant_id") or "").strip() or None
|
||
if tenant_id:
|
||
_require_tenant_scope(ctx, tenant_id)
|
||
user_id = str(payload.get("user_id") or "").strip() or None
|
||
from svc.embeddings.embedding_client import build_default_embedding_client
|
||
from runtime.orchestration.vector_store import build_vector_store, MemoryVectorItem
|
||
|
||
client = build_default_embedding_client()
|
||
vector = build_vector_store(store)
|
||
items = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=500, offset=0)
|
||
updated = 0
|
||
for row in items:
|
||
content = str(row.get("content") or "").strip()
|
||
if not content:
|
||
continue
|
||
emb = client.embed(content)
|
||
item = MemoryVectorItem(
|
||
memory_id=str(row.get("memory_id") or ""),
|
||
tenant_id=str(row.get("tenant_id") or ""),
|
||
user_id=str(row.get("user_id") or ""),
|
||
session_id=str(row.get("session_id") or ""),
|
||
memory_type=str(row.get("memory_type") or "semantic_memory"),
|
||
content=content,
|
||
confidence=float(row.get("confidence") or 0.0),
|
||
created_at=str(row.get("created_at") or ""),
|
||
updated_at=str(row.get("updated_at") or ""),
|
||
expires_at=str(row.get("expires_at") or "") or None,
|
||
metadata=row.get("metadata") if isinstance(row.get("metadata"), dict) else {},
|
||
)
|
||
vector.upsert(item, emb.vector, model=emb.model)
|
||
updated += 1
|
||
return {"ok": True, "reindexed": updated}
|
||
|
||
@router.get("/admin/api/memory/config")
|
||
def api_memory_config(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
runtime = read_vector_memory_runtime(store)
|
||
rag_mode = str(store.get_setting("rag_mode") or store.get_setting("AIA_RAG_MODE") or "keyword").strip().lower()
|
||
if rag_mode not in {"keyword", "vector"}:
|
||
rag_mode = "keyword"
|
||
emb_mode = str(store.get_setting("AIA_RAG_EMBEDDING_MODE") or "").strip().lower()
|
||
if emb_mode not in {"", "openai", "hash", "offline"}:
|
||
emb_mode = ""
|
||
ttl_raw = str(
|
||
store.get_setting("AIA_MEMORY_EPISODIC_TTL_DAYS")
|
||
or store.get_setting("MEMORY_EPISODIC_TTL_DAYS")
|
||
or "90"
|
||
).strip()
|
||
try:
|
||
ttl_days = max(1, min(int(float(ttl_raw)), 3650))
|
||
except Exception:
|
||
ttl_days = 90
|
||
cfg = dict(runtime.__dict__)
|
||
cfg["rag_mode"] = rag_mode
|
||
cfg["rag_embedding_mode"] = emb_mode
|
||
cfg["memory_episodic_ttl_days"] = ttl_days
|
||
return {"ok": True, "config": cfg}
|
||
|
||
@router.post("/admin/api/memory/config")
|
||
def api_memory_config_save(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:memory:write")
|
||
store.set_setting("MEMORY_VECTOR_ENABLED", "1" if str(payload.get("enabled") or "").lower() in ("1", "true", "yes", "on") else "0")
|
||
backend = str(payload.get("backend") or "sqlite").strip().lower()
|
||
if backend != "sqlite":
|
||
backend = "sqlite"
|
||
store.set_setting("MEMORY_VECTOR_BACKEND", backend)
|
||
store.set_setting("MEMORY_VECTOR_TOPK", str(payload.get("top_k") or 5))
|
||
store.set_setting(
|
||
"MEMORY_WRITE_ENABLED",
|
||
"1" if str(payload.get("writer_enabled") or "").lower() in ("1", "true", "yes", "on") else "0",
|
||
)
|
||
store.set_setting("MEMORY_WRITE_MIN_CONFIDENCE", str(payload.get("write_min_confidence") or 0.75))
|
||
rag_mode = str(payload.get("rag_mode") or "keyword").strip().lower()
|
||
if rag_mode not in {"keyword", "vector"}:
|
||
rag_mode = "keyword"
|
||
emb_mode = str(payload.get("rag_embedding_mode") or "").strip().lower()
|
||
if emb_mode not in {"", "openai", "hash", "offline"}:
|
||
emb_mode = ""
|
||
ttl_in = payload.get("memory_episodic_ttl_days", 90)
|
||
try:
|
||
ttl_days = max(1, min(int(float(ttl_in)), 3650))
|
||
except Exception:
|
||
ttl_days = 90
|
||
store.set_setting("rag_mode", rag_mode)
|
||
store.set_setting("AIA_RAG_MODE", rag_mode)
|
||
store.set_setting("AIA_RAG_EMBEDDING_MODE", emb_mode)
|
||
store.set_setting("AIA_MEMORY_EPISODIC_TTL_DAYS", str(ttl_days))
|
||
store.set_setting("MEMORY_EPISODIC_TTL_DAYS", str(ttl_days))
|
||
return api_memory_config()
|
||
|
||
@router.post("/admin/api/memory/cleanup-low-confidence")
|
||
def api_memory_cleanup_low_conf(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:memory:write")
|
||
try:
|
||
threshold = float(payload.get("max_confidence") or 0.35)
|
||
except Exception:
|
||
threshold = 0.35
|
||
deleted = store.clear_low_confidence_memory(max_confidence=threshold)
|
||
return {"ok": True, "deleted": deleted, "max_confidence": threshold}
|
||
|
||
@router.post("/admin/api/secrets/migrate")
|
||
def api_secrets_migrate(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
"""Migrate legacy b64 secrets to fernet (requires AIA_ASSISTANT_MASTER_KEY)."""
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:tenant:write")
|
||
try:
|
||
res = store.migrate_secrets_to_fernet()
|
||
except Exception as e:
|
||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=ctx["tenant_id"],
|
||
actor_user_id=ctx["user_id"],
|
||
action="secrets_migrate_to_fernet",
|
||
target_type="secrets",
|
||
target_id="all",
|
||
status="ok",
|
||
detail=res,
|
||
)
|
||
return {"ok": True, **res}
|
||
|
||
@router.get("/admin/api/secrets/status")
|
||
def api_secrets_status(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
"""Expose legacy secret stats for admin UI warnings."""
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
stats = store.legacy_secret_stats()
|
||
import os
|
||
|
||
has_master_key = bool((os.getenv("AIA_ASSISTANT_MASTER_KEY") or "").strip())
|
||
return {"ok": True, "has_master_key": has_master_key, **stats}
|
||
|
||
@router.post("/admin/api/auth/bootstrap")
|
||
def api_auth_bootstrap() -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
_ensure_admin_bootstrap(store)
|
||
return {"ok": True}
|
||
|
||
@router.post("/admin/api/auth/login")
|
||
def api_auth_login(payload: dict[str, Any] | None = Body(default=None)) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
_ensure_admin_bootstrap(store)
|
||
purpose = str(payload.get("purpose") or "console").strip().lower()
|
||
if purpose not in {"console", "chat"}:
|
||
purpose = "console"
|
||
if purpose == "console":
|
||
username = str(payload.get("username") or "administrator").strip().lower()
|
||
else:
|
||
username = str(payload.get("username") or "").strip().lower()
|
||
tenant_id = _resolve_login_tenant_id(
|
||
store,
|
||
username=username,
|
||
tenant_id=str(payload.get("tenant_id") or "").strip(),
|
||
)
|
||
password = str(payload.get("password") or "").strip()
|
||
if not tenant_id or not password:
|
||
return {"ok": False, "error": "tenant_id, username, password are required"}
|
||
if not username:
|
||
return {"ok": False, "error": "tenant_id, username, password are required"}
|
||
user = store.get_user_by_username(tenant_id=tenant_id, username=username)
|
||
if not user:
|
||
return {"ok": False, "error": "invalid_credentials"}
|
||
if not bool(user.get("is_active")):
|
||
return {"ok": False, "error": "user_disabled"}
|
||
expected = str(user.get("password_hash") or "")
|
||
if not expected or not hmac.compare_digest(expected, _sha256_hex(password)):
|
||
return {"ok": False, "error": "invalid_credentials"}
|
||
role = str(user.get("role") or "member")
|
||
if purpose == "console":
|
||
if role == "owner" and username == "administrator":
|
||
uid = str(user.get("id") or "")
|
||
if uid and store.update_user_account(tenant_id=tenant_id, user_id=uid, role="admin"):
|
||
user = store.get_user_by_username(tenant_id=tenant_id, username=username) or user
|
||
role = str(user.get("role") or "member")
|
||
perms = store.list_user_permissions(
|
||
tenant_id=tenant_id,
|
||
user_id=str(user.get("id") or ""),
|
||
role=role,
|
||
)
|
||
token = secrets.token_urlsafe(32)
|
||
expires = (_now_utc() + timedelta(hours=12)).isoformat()
|
||
store.create_auth_session(
|
||
session_token_hash=_sha256_hex(token),
|
||
tenant_id=tenant_id,
|
||
user_id=str(user.get("id") or ""),
|
||
role=role,
|
||
expires_at=expires,
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"token": token,
|
||
"session": {
|
||
"tenant_id": tenant_id,
|
||
"user_id": str(user.get("id") or ""),
|
||
"username": str(user.get("username") or ""),
|
||
"display_name": str(user.get("display_name") or ""),
|
||
"role": str(user.get("role") or ""),
|
||
"permissions": perms,
|
||
"expires_at": expires,
|
||
},
|
||
}
|
||
|
||
@router.get("/admin/api/auth/me")
|
||
def api_auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
return {"ok": True, "session": ctx}
|
||
|
||
@router.post("/admin/api/auth/logout")
|
||
def api_auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
token = _extract_bearer(authorization)
|
||
if token:
|
||
store.revoke_auth_session(session_token_hash=_sha256_hex(token))
|
||
return {"ok": True}
|
||
|
||
@router.post("/admin/api/ops-ai/analyze-sync")
|
||
def api_ops_ai_analyze_sync(
|
||
payload: dict[str, Any] | None = Body(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
payload = payload or {}
|
||
store = get_assistant_store()
|
||
ctx = _resolve_ops_ai_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
|
||
question = str(payload.get("question") or "").strip()
|
||
dataset_ref = payload.get("dataset_ref") if isinstance(payload.get("dataset_ref"), dict) else {}
|
||
context = payload.get("context") if isinstance(payload.get("context"), dict) else {}
|
||
constraints = payload.get("constraints") if isinstance(payload.get("constraints"), dict) else {}
|
||
analysis_request_id = str(payload.get("analysis_request_id") or "").strip()
|
||
if not question:
|
||
raise HTTPException(status_code=400, detail="question_required")
|
||
|
||
severity_summary = context.get("severity_summary") if isinstance(context.get("severity_summary"), list) else []
|
||
top_alarm_codes = context.get("top_alarm_codes") if isinstance(context.get("top_alarm_codes"), list) else []
|
||
top_ne = context.get("top_ne") if isinstance(context.get("top_ne"), list) else []
|
||
findings = context.get("findings") if isinstance(context.get("findings"), list) else []
|
||
protocol_summary = context.get("protocol_summary") if isinstance(context.get("protocol_summary"), list) else []
|
||
lang = str(constraints.get("language") or "zh-CN")
|
||
interaction_mode = normalize_interaction_mode(payload.get("interaction_mode") or "expert")
|
||
selected_specialist = normalize_requested_specialist(payload.get("specialist") or "ops")
|
||
visible_profiles = store.list_llm_profiles(visible_only=True)
|
||
visible_profile_ids = {str(p.get("id") or "").strip() for p in visible_profiles if str(p.get("id") or "").strip()}
|
||
ops_ai_active_profile_id = str(store.get_setting("ops_ai_active_llm_profile_id") or "").strip()
|
||
if ops_ai_active_profile_id not in visible_profile_ids:
|
||
ops_ai_active_profile_id = ""
|
||
raw_ops_ai_bindings = parse_agent_profile_bindings(store.get_setting("ops_ai_agent_profile_bindings"))
|
||
|
||
def _ops_ai_profile_for_role(role_id: str) -> str | None:
|
||
bound = str(raw_ops_ai_bindings.get(role_id) or "").strip()
|
||
if bound and bound in visible_profile_ids:
|
||
return bound
|
||
return None
|
||
selected_profile_id = _ops_ai_profile_for_role(selected_specialist)
|
||
if not selected_profile_id:
|
||
raise HTTPException(status_code=400, detail=f"ops_ai_profile_unbound:{selected_specialist}")
|
||
|
||
ops_prompt = (
|
||
f"问题:{question}\n"
|
||
f"数据范围:{json.dumps(dataset_ref, ensure_ascii=False)}\n"
|
||
f"级别分布:{json.dumps(severity_summary, ensure_ascii=False)}\n"
|
||
f"高频告警码:{json.dumps(top_alarm_codes, ensure_ascii=False)}\n"
|
||
f"高频网元:{json.dumps(top_ne, ensure_ascii=False)}\n"
|
||
f"协议/领域分布:{json.dumps(protocol_summary, ensure_ascii=False)}\n"
|
||
f"附加发现:{json.dumps(findings, ensure_ascii=False)}\n"
|
||
"请以网络运维专家视角给出:"
|
||
"1) 根因假设;2) 证据与缺口;3) 处置优先级;4) 需要进一步采集的数据。"
|
||
)
|
||
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
user_id = str(ctx.get("user_id") or "ops-ai-service")
|
||
username = str(ctx.get("username") or "ops-ai-service")
|
||
role = str(ctx.get("role") or "service")
|
||
gw_result = None
|
||
answer = ""
|
||
gateway_error = ""
|
||
try:
|
||
apply_gateway_mcp_env_to_os()
|
||
# Service-token calls may not map to a real UI user row.
|
||
# Use internal session to avoid ui_session_owner foreign-key constraints.
|
||
ops_session = store.create_session(
|
||
title=f"ops-ai-sync:{str(dataset_ref.get('batch_id') or 'adhoc')}"
|
||
)
|
||
manager_agent = build_gateway_executor(
|
||
store,
|
||
lang=lang,
|
||
specialist="generalist",
|
||
profile_id=selected_profile_id,
|
||
openai_api_key=None,
|
||
llm_mode=None,
|
||
model=None,
|
||
base_url=None,
|
||
# Internal ops-ai should use the global model pool bindings
|
||
# instead of service-user personal visibility filtering.
|
||
viewer_user_id=None,
|
||
viewer_username="administrator",
|
||
viewer_tenant_id=None,
|
||
policy_session_id=str(ops_session.id),
|
||
path_policy_tenant_id=tenant_id or None,
|
||
path_policy_user_id=user_id or None,
|
||
)
|
||
specialist_factory = lambda sid: build_gateway_executor(
|
||
store,
|
||
lang=lang,
|
||
specialist=sid,
|
||
profile_id=_ops_ai_profile_for_role(str(sid or "").strip().lower()) or selected_profile_id,
|
||
openai_api_key=None,
|
||
llm_mode=None,
|
||
model=None,
|
||
base_url=None,
|
||
viewer_user_id=None,
|
||
viewer_username="administrator",
|
||
viewer_tenant_id=None,
|
||
policy_session_id=str(ops_session.id),
|
||
path_policy_tenant_id=tenant_id or None,
|
||
path_policy_user_id=user_id or None,
|
||
)
|
||
gw = OclawGateway(store=store)
|
||
gw_result = gw.handle_turn(
|
||
msg=StandardMessage(
|
||
session_id=str(ops_session.id),
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
role=role,
|
||
channel="ops_ai_api",
|
||
text=ops_prompt,
|
||
attachments=[],
|
||
metadata={
|
||
"tenant_id": tenant_id,
|
||
"user_id": user_id,
|
||
"role": role,
|
||
"channel": "ops_ai_api",
|
||
"interaction_mode": interaction_mode,
|
||
"selected_specialist": selected_specialist,
|
||
"memory_mode": "default",
|
||
"execution_mode": "normal",
|
||
},
|
||
),
|
||
lang=lang,
|
||
executor=manager_agent,
|
||
run_id=(analysis_request_id or None),
|
||
specialist_executor_factory=specialist_factory,
|
||
)
|
||
answer = str(getattr(gw_result, "reply_text", "") or "")
|
||
except Exception as exc:
|
||
gateway_error = str(exc)
|
||
sev_text = ";".join(
|
||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||
for x in severity_summary[:5]
|
||
if isinstance(x, dict)
|
||
)
|
||
code_text = ";".join(
|
||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||
for x in top_alarm_codes[:5]
|
||
if isinstance(x, dict)
|
||
)
|
||
ne_text = ";".join(
|
||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||
for x in top_ne[:5]
|
||
if isinstance(x, dict)
|
||
)
|
||
proto_text = ";".join(
|
||
f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}"
|
||
for x in protocol_summary[:5]
|
||
if isinstance(x, dict)
|
||
)
|
||
answer = (
|
||
"网关专家暂时不可用,先给出统计结论:\n"
|
||
f"- 级别分布:{sev_text or '无'}\n"
|
||
f"- 高频告警码:{code_text or '无'}\n"
|
||
f"- 高频网元:{ne_text or '无'}\n"
|
||
f"- 协议/领域:{proto_text or '无'}\n"
|
||
"- 建议优先处理 critical/major 的热点网元,并结合 top 告警码排查是否告警风暴。"
|
||
)
|
||
|
||
try:
|
||
store.add_admin_audit_log(
|
||
actor_tenant_id=str(ctx.get("tenant_id") or ""),
|
||
actor_user_id=str(ctx.get("user_id") or ""),
|
||
action="ops_ai_analyze_sync",
|
||
target_type="ops_ai",
|
||
target_id=analysis_request_id or str(dataset_ref.get("batch_id") or "unknown"),
|
||
status="ok",
|
||
detail={
|
||
"question": question[:200],
|
||
"dataset_ref": dataset_ref,
|
||
"risk_hint": findings[:3] if findings else [],
|
||
"mode": interaction_mode,
|
||
"specialist": selected_specialist,
|
||
"gateway_error": gateway_error,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"ok": True,
|
||
"analysis_request_id": analysis_request_id,
|
||
"answer": answer,
|
||
"evidence": {
|
||
"severity_summary": severity_summary[:10],
|
||
"top_alarm_codes": top_alarm_codes[:10],
|
||
"top_ne": top_ne[:10],
|
||
"protocol_summary": protocol_summary[:10],
|
||
"findings": findings[:10],
|
||
},
|
||
"followups": [
|
||
"请给出过去2小时 critical 告警的 top5 网元与同比变化。",
|
||
"请按告警码聚类,定位是否为单一根因引发的告警风暴。",
|
||
],
|
||
"metadata": {
|
||
"engine": "oclaw_ops_ai_sync_v1",
|
||
"source": "gateway_expert" if not gateway_error else "fallback_context",
|
||
"interaction_mode": str(getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode),
|
||
"selected_specialist": str(getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist),
|
||
"ops_ai_active_profile_id": ops_ai_active_profile_id,
|
||
"ops_ai_role_profile_id": _ops_ai_profile_for_role(selected_specialist) or "",
|
||
"gateway_error": gateway_error,
|
||
"caller": str(ctx.get("username") or ""),
|
||
},
|
||
}
|
||
|
||
@router.get("/admin/api/ops-ai/logs")
|
||
def api_ops_ai_logs(
|
||
limit: int = Query(default=50),
|
||
offset: int = Query(default=0),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_ops_ai_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
lim = max(1, min(int(limit), 200))
|
||
off = max(0, int(offset))
|
||
rows = store.list_admin_audit_logs(
|
||
tenant_id=str(ctx.get("tenant_id") or ""),
|
||
action="ops_ai_analyze_sync",
|
||
actor_user_id=None,
|
||
status=None,
|
||
limit=lim,
|
||
offset=off,
|
||
)
|
||
total = store.count_admin_audit_logs(
|
||
tenant_id=str(ctx.get("tenant_id") or ""),
|
||
action="ops_ai_analyze_sync",
|
||
actor_user_id=None,
|
||
status=None,
|
||
)
|
||
return {"ok": True, "items": rows, "total": int(total), "limit": lim, "offset": off}
|
||
|
||
@router.get("/admin/api/ops-ai/health")
|
||
def api_ops_ai_health(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_ops_ai_auth(store, authorization)
|
||
_require_permission(ctx, "admin:read")
|
||
return {"ok": True, "service": "oclaw", "component": "ops-ai", "status": "ok", "caller": str(ctx.get("username") or "")}
|
||
|
||
@router.get("/admin/api/admin-audit")
|
||
def api_admin_audit(
|
||
limit: int = Query(default=200),
|
||
offset: int = Query(default=0),
|
||
action: str | None = Query(default=None),
|
||
actor_user_id: str | None = Query(default=None),
|
||
status: str | None = Query(default=None),
|
||
authorization: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
store = get_assistant_store()
|
||
ctx = _resolve_auth(store, authorization)
|
||
_require_permission(ctx, "admin:user:write")
|
||
a = str(action or "").strip()
|
||
actor = str(actor_user_id or "").strip()
|
||
st = str(status or "").strip()
|
||
lim = max(1, min(int(limit), 500))
|
||
off = max(0, int(offset))
|
||
tenant_id = str(ctx.get("tenant_id") or "")
|
||
rows = store.list_admin_audit_logs(
|
||
tenant_id=tenant_id,
|
||
action=a or None,
|
||
actor_user_id=actor or None,
|
||
status=st or None,
|
||
limit=lim,
|
||
offset=off,
|
||
)
|
||
total = store.count_admin_audit_logs(
|
||
tenant_id=tenant_id,
|
||
action=a or None,
|
||
actor_user_id=actor or None,
|
||
status=st or None,
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"items": rows,
|
||
"logs": rows,
|
||
"total": int(total),
|
||
"limit": int(lim),
|
||
"offset": int(off),
|
||
}
|
||
|
||
from interfaces.admin.chat_api import include_chat_routes
|
||
from interfaces.admin.models_api import include_model_mgmt_routes
|
||
from interfaces.admin.skills_api import include_skill_routes
|
||
|
||
include_chat_routes(router, resolve_auth=_resolve_auth)
|
||
include_model_mgmt_routes(router, resolve_auth=_resolve_auth)
|
||
include_skill_routes(router, resolve_auth=_resolve_auth)
|
||
|
||
return router
|
||
|
||
|
||
__all__ = ["build_admin_router", "admin_static_dir"]
|
||
|