Files
hunjing/backend/app/main.py
T
chenxin 684261f4b4 M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证
- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明)
- 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301
- 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账
- MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch)
- 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证)
- 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页
- 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
2026-08-27 19:17:21 +08:00

219 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI 应用入口。
既导出 ASGI 应用实例 ``app``(供 ``uvicorn app.main:app`` 使用),又在作为脚本执行
(``python -m app.main``)时启动 uvicorn。日志系统在模块级初始化,确保无论从哪种
入口启动,uvicorn 横幅、第三方库与应用日志都统一走 ``app.core.logging`` 出口。
"""
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from time import perf_counter
import httpx
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.exc import OperationalError
from app.api.router import api_router
from app.core.config import settings
from app.core.errors import register_exception_handlers, trace_id_var
from app.core.ids import new_trace_id
from app.core.logging import (
clear_log_context,
log_access,
log_with_context,
set_request_id,
setup_logging,
)
from app.db.init_data import seed_default_data
from app.db.session import AsyncSessionLocal, close_engine
from app.domains.baigong.executor import Executor
from app.integrations.sandbox.client import SandboxClient
from app.schemas.common import ErrorResponse
from app.services.dispatcher import Dispatcher
from app.services.event_store import DbEventStore
# 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging,
# 覆盖 ``python -m app.main`` 与 ``uvicorn app.main:app`` 两种启动入口。
setup_logging()
log_with_context(
"应用日志已初始化",
log_dir=str(settings.resolved_log_dir),
log_level=settings.log_level,
app_env=settings.app_env,
)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
try:
async with AsyncSessionLocal() as session:
await seed_default_data(session)
except OperationalError:
log_with_context("数据库尚未完成迁移,跳过默认数据初始化。", level="WARNING")
# SQLite WAL 模式:用 sqlite3 直接在文件层面设置(必须跑在事务外才能生效),
# 后续所有 aiosqlite 连接自动继承 WAL 模式;允许读写并发,
# 避免叩心枢后台循环与请求处理同时写时锁冲突
if settings.resolved_database_url.startswith("sqlite"):
try:
import sqlite3
_db_path = settings.resolved_database_url.replace("sqlite+aiosqlite:///", "")
_raw_conn = sqlite3.connect(_db_path)
_raw_conn.execute("PRAGMA journal_mode=WAL")
_raw_conn.execute("PRAGMA busy_timeout=5000")
_raw_conn.close()
log_with_context("SQLite WAL 模式已启用,busy_timeout=5000ms")
except Exception: # noqa: BLE001
log_with_context("SQLite WAL 模式设置失败,已跳过", level="WARNING")
# 运行时注入:事件存储始终就绪(DbEventStore 持久化到 events 表,重启不丢);
# 工具运行时依赖沙箱配置,未配置(缺 SANDBOX_AUTH_SECRET 等)则降级 None(工具 API 503)。
# 事件流与历史查询不依赖沙箱,始终可用。
_app.state.event_store = DbEventStore(AsyncSessionLocal)
# Kernel(rewrite/1.0 M0):极小硬核 + 事件日志 + 前台席位;神识客户端按配置装配,
# 未配置神识地址时为进程内回声桩(骨架形态,见 app.shenshi_client)。
from app.kernel import Kernel, KernelEventLog
from app.shenshi_client import build_shenshi_client
_app.state.kernel = Kernel(
shenshi=build_shenshi_client(settings.shenshi_base_url, settings.shenshi_service_token),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(_app.state.event_store),
timeout_seconds=float(settings.run_timeout_seconds),
)
# 镜生域事件写入绑定一炁(灵胚/启镜/命台/死亡事件落 events 表)
from app.domains.jingsheng import bind_event_store
bind_event_store(_app.state.event_store)
# ── MCP 进程管理器(stdio 子进程生命周期管理) ──────────────
from app.integrations.mcp.process_manager import (
ProcessManager,
set_process_manager,
)
process_manager = ProcessManager()
_app.state.mcp_process_manager = process_manager
set_process_manager(process_manager)
log_with_context("MCP 进程管理器就绪")
# 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。
# 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。
_app.state.dispatcher = Dispatcher(_app.state.event_store)
await _app.state.dispatcher.start()
# 百工执行器:乾坤沙箱客户端(HMAC 授契)+ 长生命周期 HTTP;未配置时乾坤类能力 503
sandbox_http: httpx.AsyncClient | None = None
try:
if settings.sandbox_auth_enabled and not settings.sandbox_auth_secret:
raise ValueError("SANDBOX_AUTH_ENABLED=true 但 SANDBOX_AUTH_SECRET 未配置")
sandbox_http = httpx.AsyncClient(trust_env=False)
sandbox = SandboxClient(sandbox_http, settings)
_app.state.baigong_sandbox = sandbox
_app.state.baigong_executor = Executor(sandbox, http_client=sandbox_http)
log_with_context("百工执行器就绪(乾坤沙箱已接入)")
except Exception as exc: # noqa: BLE001 — 配置缺失降级,不阻断启动
_app.state.baigong_sandbox = None
_app.state.baigong_executor = Executor(None)
log_with_context(f"乾坤沙箱未就绪,qiankun 类能力将返回 503:{exc}", level="WARNING")
# 启动登记 MCP 传输实例(stdio 不自动启动;派生能力已在登记/刷新时落库)
try:
from app.services.mcp import load_registered_mcp_servers
await load_registered_mcp_servers(
process_manager=_app.state.mcp_process_manager,
)
except Exception: # noqa: BLE001
log_with_context("MCP 启动加载失败,已跳过", level="WARNING")
yield
# Dispatcher 先停(取消 pump/consumer task + unsubscribe all_queue),再关 http/engine
if getattr(_app.state, "dispatcher", None) is not None:
await _app.state.dispatcher.stop()
if sandbox_http is not None:
await sandbox_http.aclose()
# MCP 进程管理器关闭所有 stdio 子进程
if getattr(_app.state, "mcp_process_manager", None) is not None:
await _app.state.mcp_process_manager.shutdown_all()
log_with_context("MCP stdio 子进程已全部关闭")
await close_engine()
def create_app() -> FastAPI:
# OpenAPI servers 仅作展示:backend_host 为通配地址(0.0.0.0 / ::)时回退 127.0.0.1,
# 避免在 Swagger 文档里暴露不可访问的地址或写死的占位域名。
server_host = (
settings.backend_host if settings.backend_host not in ("0.0.0.0", "::") else "127.0.0.1"
)
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
lifespan=lifespan,
servers=[
{"url": f"http://{server_host}:{settings.backend_port}", "description": "本地开发"},
],
responses={
400: {"model": ErrorResponse, "description": "请求无效或业务前置条件不满足。"},
404: {"model": ErrorResponse, "description": "资源不存在或不属于当前人物。"},
422: {"model": ErrorResponse, "description": "请求字段校验失败。"},
502: {"model": ErrorResponse, "description": "模型或外部工具调用失败。"},
},
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def trace_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
trace_id = request.headers.get("X-Trace-Id") or new_trace_id()
token = trace_id_var.set(trace_id)
set_request_id(trace_id)
started_at = perf_counter()
try:
response = await call_next(request)
response.headers["X-Trace-Id"] = trace_id
duration_ms = round((perf_counter() - started_at) * 1000, 2)
log_access(
"HTTP 请求完成",
method=request.method,
path=request.url.path,
status=response.status_code,
duration_ms=duration_ms,
)
return response
finally:
trace_id_var.reset(token)
clear_log_context()
register_exception_handlers(app)
app.include_router(api_router, prefix=settings.api_prefix)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.backend_host,
port=settings.backend_port,
reload=settings.is_development,
workers=1 if settings.is_development else 4,
log_config=None, # 禁用 uvicorn 默认 dictConfig,统一由 InterceptHandler 接管
)