2380 lines
94 KiB
Python
2380 lines
94 KiB
Python
# ruff: noqa: E402, I001
|
||
# The dotenv load below must precede application imports that read environment.
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import queue
|
||
import sys
|
||
import threading
|
||
from collections.abc import AsyncGenerator
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from time import perf_counter
|
||
from typing import Any
|
||
|
||
from dotenv import load_dotenv
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||
from fastapi.responses import HTMLResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
# Load .env BEFORE any module that reads os.environ at import time.
|
||
# Without this, GATEWAY_CORS_ORIGINS / AUTH_JWT_SECRET / DEER_FLOW_AUTH_DISABLED
|
||
# placed in /workspace/.env are silently ignored when the container is started
|
||
# without explicit `-e` flags, leading to ephemeral JWT secrets, missing CORS
|
||
# origins, and "Failed to fetch" in the browser.
|
||
load_dotenv()
|
||
|
||
from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
|
||
from app.gateway.auth_middleware import AuthMiddleware
|
||
from app.gateway.config import get_gateway_config
|
||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||
from app.gateway.deps import langgraph_runtime
|
||
from app.gateway.public_knowledge_cors import PublicKnowledgeCORSMiddleware
|
||
from app.gateway.routers import (
|
||
admin_active_runs,
|
||
admin_audit_retention as admin_audit_retention_routes,
|
||
admin_users,
|
||
agents,
|
||
ai_writing,
|
||
app_identity,
|
||
artifacts,
|
||
assistant_knowledge,
|
||
assistants_compat,
|
||
auth,
|
||
service_tokens,
|
||
browser_context,
|
||
business_mapping,
|
||
channels,
|
||
chat_archive,
|
||
checkpoint_migration,
|
||
compact_menu_overrides,
|
||
dashboard_sessions,
|
||
deep_research,
|
||
direct_chat,
|
||
external_llmwiki,
|
||
feedback,
|
||
fixed_questions,
|
||
flow_extract,
|
||
force_situation,
|
||
gongwen_documents,
|
||
html_images,
|
||
html_page_favorites,
|
||
html_runtime,
|
||
html_templates,
|
||
intent,
|
||
knowledge,
|
||
knowledge_ingest,
|
||
light_apps,
|
||
llm_metrics,
|
||
llmwiki,
|
||
llmwiki_index,
|
||
mcp,
|
||
memory,
|
||
menu_overrides,
|
||
model_exchanges,
|
||
model_management,
|
||
models,
|
||
multi_agent,
|
||
notifications,
|
||
open_3qfx,
|
||
open_chat,
|
||
parallel_agent_tasks,
|
||
parallel_agents,
|
||
position_roles,
|
||
position_roundtable,
|
||
positions,
|
||
public_config,
|
||
public_embed,
|
||
public_knowledge_vector_search,
|
||
public_leaderboard,
|
||
public_scheduled_tasks,
|
||
recommend,
|
||
recommended_questions,
|
||
report_collaboration,
|
||
report_structures,
|
||
roundtable_artifact_submissions,
|
||
roundtable_chains,
|
||
roundtable_diagnostics,
|
||
roundtable_draft_shares,
|
||
roundtable_drafts,
|
||
roundtable_jobs,
|
||
roundtable_rooms,
|
||
roundtable_task_drafts,
|
||
runs,
|
||
scheduled_tasks,
|
||
sensitive_words,
|
||
sentiment_agent,
|
||
skill_addresses,
|
||
skill_knowledge,
|
||
skill_view_diagnostics,
|
||
skills,
|
||
strategy_agents,
|
||
stream_truncations,
|
||
suggestions,
|
||
system_settings,
|
||
tags,
|
||
task_analysis,
|
||
task_analysis_qa,
|
||
task_buttons,
|
||
task_deeplink_details,
|
||
task_deeplink_threads,
|
||
task_reports,
|
||
taskcop_tasks,
|
||
thread_runs,
|
||
thread_shares,
|
||
threads,
|
||
tool_metrics,
|
||
upload_policy,
|
||
uploads,
|
||
user_preferences,
|
||
user_prompts,
|
||
web_app_sites,
|
||
workflow_data_sources,
|
||
workflow_embed,
|
||
workflow_planning,
|
||
workflow_resources,
|
||
workflow_runs,
|
||
workflow_studio,
|
||
workflows,
|
||
workflows_coze_compat,
|
||
writing,
|
||
)
|
||
from app.gateway.runtime_executor import configure_runtime_executor # noqa: E402
|
||
from deerflow.config import app_config as deerflow_app_config
|
||
from deerflow.config.app_config import apply_logging_level
|
||
from deerflow.runtime.scheduler import ScheduledTaskService, set_scheduled_task_service
|
||
|
||
AppConfig = deerflow_app_config.AppConfig
|
||
get_app_config = deerflow_app_config.get_app_config
|
||
|
||
# Default logging; lifespan overrides from config.yaml log_level.
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S",
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Swagger UI and ReDoc are vendored so the API documentation works in isolated
|
||
# intranets. The route intentionally lives below /docs so it is covered by the
|
||
# existing anonymous docs allow-list in the auth middleware.
|
||
_DOCS_STATIC_DIR = Path(__file__).with_name("static") / "docs"
|
||
|
||
# Upper bound (seconds) each lifespan shutdown hook is allowed to run.
|
||
# Bounds worker exit time so uvicorn's reload supervisor does not keep
|
||
# firing signals into a worker that is stuck waiting for shutdown cleanup.
|
||
_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
|
||
|
||
|
||
async def _ensure_admin_user(app: FastAPI) -> None:
|
||
"""Startup hook: handle first boot and migrate orphan threads otherwise.
|
||
|
||
After admin creation, migrate orphan threads from the LangGraph
|
||
store (metadata.user_id unset) to the admin account. This is the
|
||
"no-auth ? with-auth" upgrade path: users who ran DeerFlow without
|
||
authentication have existing LangGraph thread data that needs an
|
||
owner assigned.
|
||
First boot (no admin exists):
|
||
- Does NOT create any user accounts automatically.
|
||
- The operator must visit ``/setup`` to create the first admin.
|
||
|
||
Subsequent boots (admin already exists):
|
||
- Runs the one-time "no-auth ? with-auth" orphan thread migration for
|
||
existing LangGraph thread metadata that has no owner_id.
|
||
|
||
No SQL persistence migration is needed: the four user_id columns
|
||
(threads_meta, runs, run_events, feedback) only come into existence
|
||
alongside the auth module via create_all, so freshly created tables
|
||
never contain NULL-owner rows.
|
||
"""
|
||
from sqlalchemy import select
|
||
|
||
from app.gateway.deps import get_local_provider
|
||
from deerflow.persistence.engine import get_session_factory
|
||
from deerflow.persistence.user.model import UserRow
|
||
|
||
try:
|
||
provider = get_local_provider()
|
||
except RuntimeError:
|
||
# Auth persistence may not be initialized in some test/boot paths.
|
||
# Skip admin migration work rather than failing gateway startup.
|
||
logger.warning("Auth persistence not ready; skipping admin bootstrap check")
|
||
return
|
||
|
||
sf = get_session_factory()
|
||
if sf is None:
|
||
return
|
||
|
||
admin_count = await provider.count_admin_users()
|
||
|
||
if admin_count == 0:
|
||
logger.info("=" * 60)
|
||
logger.info(" First boot detected ? no admin account exists.")
|
||
logger.info(" Visit /setup to complete admin account creation.")
|
||
logger.info("=" * 60)
|
||
return
|
||
|
||
# Admin already exists ? run orphan thread migration for any
|
||
# LangGraph thread metadata that pre-dates the auth module.
|
||
async with sf() as session:
|
||
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
|
||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||
|
||
if row is None:
|
||
return # Should not happen (admin_count > 0 above), but be safe.
|
||
|
||
admin_id = str(row.id)
|
||
|
||
# LangGraph store orphan migration ? non-fatal.
|
||
# This covers the "no-auth ? with-auth" upgrade path for users
|
||
# whose existing LangGraph thread metadata has no user_id set.
|
||
store = getattr(app.state, "store", None)
|
||
if store is not None:
|
||
try:
|
||
migrated = await _migrate_orphaned_threads(store, admin_id)
|
||
if migrated:
|
||
logger.info("Migrated %d orphan LangGraph thread(s) to admin", migrated)
|
||
except Exception:
|
||
logger.exception("LangGraph thread migration failed (non-fatal)")
|
||
|
||
|
||
async def _iter_store_items(store, namespace, *, page_size: int = 500):
|
||
"""Paginated async iterator over a LangGraph store namespace.
|
||
|
||
Replaces the old hardcoded ``limit=1000`` call with a cursor-style
|
||
loop so that environments with more than one page of orphans do
|
||
not silently lose data. Terminates when a page is empty OR when a
|
||
short page arrives (indicating the last page).
|
||
"""
|
||
offset = 0
|
||
while True:
|
||
batch = await store.asearch(namespace, limit=page_size, offset=offset)
|
||
if not batch:
|
||
return
|
||
for item in batch:
|
||
yield item
|
||
if len(batch) < page_size:
|
||
return
|
||
offset += page_size
|
||
|
||
|
||
async def _sync_legacy_skills(app: FastAPI) -> list[Any] | None:
|
||
"""Idempotently register existing on-disk ``skills/custom/*`` directories
|
||
in the ``skills`` DB table, owned by the first admin and published=True.
|
||
|
||
Without this step, after upgrading to per-user skill ownership the existing
|
||
custom skill folders would not be visible to anyone (the SkillStore
|
||
visibility filter relies on a DB row). Running this once on every boot is
|
||
cheap (folder scan + one ``ensure_legacy`` per name) and idempotent.
|
||
|
||
Non-fatal: a failure here MUST NOT block server startup.
|
||
"""
|
||
from sqlalchemy import select
|
||
|
||
from deerflow.persistence.engine import get_session_factory
|
||
from deerflow.persistence.user.model import UserRow
|
||
from deerflow.skills.storage import get_or_new_skill_storage
|
||
from deerflow.skills.types import SkillCategory
|
||
|
||
sf = get_session_factory()
|
||
if sf is None:
|
||
return None
|
||
|
||
skill_store = getattr(app.state, "skill_store", None)
|
||
if skill_store is None:
|
||
return None
|
||
|
||
async with sf() as session:
|
||
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
|
||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||
if row is None:
|
||
# No admin yet ? leave ownership null; first admin is responsible for
|
||
# claiming via the UI once they exist.
|
||
admin_id: str | None = None
|
||
else:
|
||
admin_id = str(row.id)
|
||
|
||
config = getattr(app.state, "config", None)
|
||
storage = get_or_new_skill_storage(app_config=config)
|
||
try:
|
||
skills = storage.load_skills(enabled_only=False)
|
||
except Exception:
|
||
logger.exception("_sync_legacy_skills: could not list on-disk skills")
|
||
return None
|
||
|
||
inserted = 0
|
||
for skill in skills:
|
||
if skill.category != SkillCategory.CUSTOM:
|
||
continue
|
||
try:
|
||
existing = await skill_store.get_any(skill.name)
|
||
if existing is not None:
|
||
continue
|
||
await skill_store.ensure_legacy(
|
||
{"name": skill.name, "owner_user_id": admin_id, "published": True}
|
||
)
|
||
inserted += 1
|
||
except Exception:
|
||
logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True)
|
||
if inserted:
|
||
logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted)
|
||
return skills
|
||
|
||
|
||
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||
"""Migrate LangGraph store threads with no user_id to the given admin.
|
||
|
||
Uses cursor pagination so all orphans are migrated regardless of
|
||
count. Returns the number of rows migrated.
|
||
"""
|
||
migrated = 0
|
||
async for item in _iter_store_items(store, ("threads",)):
|
||
metadata = item.value.get("metadata", {})
|
||
if not metadata.get("user_id"):
|
||
metadata["user_id"] = admin_user_id
|
||
item.value["metadata"] = metadata
|
||
await store.aput(("threads",), item.key, item.value)
|
||
migrated += 1
|
||
return migrated
|
||
|
||
|
||
async def _run_db_migrations() -> None:
|
||
"""Run Alembic migrations at startup so schema changes are applied automatically."""
|
||
from pathlib import Path
|
||
|
||
from alembic import command
|
||
from alembic.config import Config
|
||
|
||
import deerflow.persistence as _persistence_pkg
|
||
from deerflow.persistence.engine import get_engine
|
||
|
||
engine = get_engine()
|
||
if engine is None:
|
||
logger.info("Persistence backend=memory ? skipping Alembic migrations")
|
||
return
|
||
|
||
from deerflow.persistence.gauss_mysql_compat import is_gauss_mysql_product
|
||
|
||
if is_gauss_mysql_product():
|
||
logger.warning("Database product=gauss_mysql; skipping MySQL-specific Alembic migrations")
|
||
return
|
||
|
||
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
|
||
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
|
||
return
|
||
|
||
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
|
||
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
|
||
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
|
||
|
||
def _run_sync() -> None:
|
||
try:
|
||
cfg = Config()
|
||
cfg.set_main_option("script_location", migrations_dir)
|
||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||
command.upgrade(cfg, "head")
|
||
except BaseException as exc:
|
||
result_queue.put(exc)
|
||
else:
|
||
result_queue.put(None)
|
||
|
||
try:
|
||
try:
|
||
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
|
||
except ValueError:
|
||
timeout_s = 60.0
|
||
|
||
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
|
||
worker.start()
|
||
try:
|
||
deadline = asyncio.get_running_loop().time() + timeout_s
|
||
while result_queue.empty():
|
||
if asyncio.get_running_loop().time() >= deadline:
|
||
raise TimeoutError
|
||
await asyncio.sleep(0.2)
|
||
result = result_queue.get_nowait()
|
||
except TimeoutError:
|
||
logger.error(
|
||
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
|
||
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
|
||
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
|
||
timeout_s,
|
||
)
|
||
return
|
||
if result is not None:
|
||
raise result
|
||
logger.info("Database migrations applied (head)")
|
||
except Exception:
|
||
logger.exception("Database migration failed ? schema may be out of date")
|
||
|
||
|
||
# Skill curator ?????
|
||
# - ????:?????????"??????";??????? should_run_now() ???
|
||
# - ????:?????????????,???????
|
||
_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 ??
|
||
_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 ??
|
||
|
||
|
||
def _acquire_singleton_lock(lock_path):
|
||
"""???????????,??? worker ?? leader ???
|
||
|
||
?? -> ??????????(?????????,??????/????);
|
||
????(???? worker ??)????? -> ?? None?
|
||
??????? OS ????????
|
||
"""
|
||
try:
|
||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
handle = open(lock_path, "a+")
|
||
except OSError:
|
||
logger.exception("Curator scheduler: ???????")
|
||
return None
|
||
try:
|
||
if sys.platform == "win32":
|
||
import msvcrt
|
||
|
||
handle.seek(0)
|
||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||
else:
|
||
import fcntl
|
||
|
||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except OSError:
|
||
handle.close()
|
||
return None
|
||
return handle
|
||
|
||
|
||
def _release_singleton_lock(handle) -> None:
|
||
"""????? _acquire_singleton_lock ?????????"""
|
||
try:
|
||
if sys.platform == "win32":
|
||
import msvcrt
|
||
|
||
handle.seek(0)
|
||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||
else:
|
||
import fcntl
|
||
|
||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||
except OSError:
|
||
pass
|
||
finally:
|
||
try:
|
||
handle.close()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
async def _curator_scheduler_loop() -> None:
|
||
"""??????:?? _CURATOR_CHECK_INTERVAL_SECONDS ???????????
|
||
|
||
? worker ???????? leader ?? ?? ???????? worker ????
|
||
????,?? worker ????,?? curator ?????????? LLM?
|
||
? cancel ? CancelledError ??? ``except Exception``(?? BaseException
|
||
??,?????),?????????? finally ?????
|
||
"""
|
||
from deerflow.config.runtime_paths import runtime_home
|
||
from deerflow.skills.curator import maybe_run_curator
|
||
|
||
lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock")
|
||
if lock_handle is None:
|
||
logger.info("Curator scheduler: ??? worker ??????,? worker ??")
|
||
return
|
||
|
||
logger.info("Curator scheduler: ?????,? worker ?? curator ??")
|
||
try:
|
||
# ?????????????,??????
|
||
await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS)
|
||
while True:
|
||
try:
|
||
await maybe_run_curator()
|
||
except Exception:
|
||
logger.exception("Curator scheduler tick failed (non-fatal)")
|
||
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
|
||
finally:
|
||
_release_singleton_lock(lock_handle)
|
||
|
||
|
||
def _env_bool(name: str, default: bool) -> bool:
|
||
raw = os.getenv(name)
|
||
if raw is None:
|
||
return default
|
||
return raw.strip().lower() not in {"0", "false", "no", "off"}
|
||
|
||
|
||
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
|
||
raw = os.getenv(name)
|
||
if raw is None and alias:
|
||
raw = os.getenv(alias)
|
||
if raw is None:
|
||
raw = str(default)
|
||
try:
|
||
value = int(raw)
|
||
except ValueError:
|
||
return default
|
||
return max(minimum, value)
|
||
|
||
|
||
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
|
||
raw = os.getenv(name)
|
||
if raw is None and alias:
|
||
raw = os.getenv(alias)
|
||
if raw is None:
|
||
raw = str(default)
|
||
try:
|
||
value = float(raw)
|
||
except ValueError:
|
||
return default
|
||
return max(minimum, value)
|
||
|
||
|
||
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
|
||
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
|
||
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
|
||
30,
|
||
)
|
||
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
|
||
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
|
||
1,
|
||
)
|
||
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
|
||
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
|
||
300.0,
|
||
)
|
||
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
|
||
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
|
||
2.0,
|
||
)
|
||
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
|
||
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
|
||
1800,
|
||
)
|
||
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
|
||
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
|
||
60.0,
|
||
)
|
||
# Concurrency (system-pressure) sampler: how often to snapshot the in-flight run
|
||
# count, and how long to keep samples (drives the admin 并发量 line chart, which
|
||
# aggregates per minute/hour). Sampling is in-memory cheap (one tiny INSERT).
|
||
_CONCURRENCY_SAMPLE_ENABLED = _env_bool("CONCURRENCY_SAMPLE_ENABLED", True)
|
||
_CONCURRENCY_SAMPLE_INTERVAL_SECONDS = _env_float(
|
||
"CONCURRENCY_SAMPLE_INTERVAL_SECONDS",
|
||
15.0,
|
||
)
|
||
_CONCURRENCY_SAMPLE_RETENTION_HOURS = _env_float(
|
||
"CONCURRENCY_SAMPLE_RETENTION_HOURS",
|
||
168.0, # 7 days
|
||
)
|
||
# Administrative audit data is operational rather than archival. The actual
|
||
# cutoff lives with the shared query policy; these controls intentionally make
|
||
# cleanup a low-priority, incremental task instead of a large startup delete.
|
||
_ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS = _env_float(
|
||
"ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS",
|
||
15 * 60,
|
||
minimum=60.0,
|
||
)
|
||
_ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS = _env_float(
|
||
"ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS",
|
||
300.0,
|
||
minimum=0.0,
|
||
)
|
||
_ADMIN_AUDIT_RETENTION_BATCH_SIZE = min(
|
||
500,
|
||
_env_int("ADMIN_AUDIT_RETENTION_BATCH_SIZE", 100, minimum=1),
|
||
)
|
||
_ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE = min(
|
||
16,
|
||
_env_int("ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE", 4, minimum=1),
|
||
)
|
||
_ADMIN_AUDIT_RETENTION_INTER_BATCH_PAUSE_SECONDS = min(
|
||
1.0,
|
||
_env_float("ADMIN_AUDIT_RETENTION_INTER_BATCH_PAUSE_SECONDS", 0.05, minimum=0.0),
|
||
)
|
||
_ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS = min(
|
||
60.0,
|
||
_env_float("ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS", 8.0, minimum=0.1),
|
||
)
|
||
# Hour (local Beijing time, 0-23) after which the scheduler force-recomputes the
|
||
# *previous* day's snapshot once, so records that landed near midnight are fully
|
||
# captured. Default 1 ? "???? 1 ????????".
|
||
_LEADERBOARD_FINALIZE_HOUR = min(
|
||
23,
|
||
_env_int("ADMIN_LEADERBOARD_FINALIZE_HOUR", 1, minimum=0),
|
||
)
|
||
|
||
|
||
def _leaderboard_scheduler_sql_for_log(stmt) -> str:
|
||
try:
|
||
return str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||
except Exception:
|
||
return str(stmt)
|
||
|
||
|
||
async def _execute_leaderboard_scheduler_query(session, step: str, stmt, **context):
|
||
sql = _leaderboard_scheduler_sql_for_log(stmt)
|
||
logger.info(
|
||
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
|
||
step,
|
||
context.get("stat_date") or context.get("stat_dates") or "",
|
||
context.get("job_key") or "",
|
||
context.get("settings_hash") or "",
|
||
datetime.now().isoformat(),
|
||
sql,
|
||
)
|
||
start = perf_counter()
|
||
try:
|
||
result = await session.execute(stmt)
|
||
except Exception:
|
||
logger.exception(
|
||
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
|
||
step,
|
||
context.get("stat_date") or context.get("stat_dates") or "",
|
||
context.get("job_key") or "",
|
||
context.get("settings_hash") or "",
|
||
(perf_counter() - start) * 1000,
|
||
)
|
||
raise
|
||
logger.info(
|
||
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
|
||
step,
|
||
context.get("stat_date") or context.get("stat_dates") or "",
|
||
context.get("job_key") or "",
|
||
context.get("settings_hash") or "",
|
||
datetime.now().isoformat(),
|
||
(perf_counter() - start) * 1000,
|
||
)
|
||
return result
|
||
|
||
|
||
async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql: str = "", **context):
|
||
logger.info(
|
||
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
|
||
step,
|
||
context.get("stat_date") or context.get("stat_dates") or "",
|
||
context.get("job_key") or "",
|
||
context.get("settings_hash") or "",
|
||
datetime.now().isoformat(),
|
||
sql,
|
||
)
|
||
start = perf_counter()
|
||
try:
|
||
result = await operation()
|
||
except Exception:
|
||
logger.exception(
|
||
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
|
||
step,
|
||
context.get("stat_date") or context.get("stat_dates") or "",
|
||
context.get("job_key") or "",
|
||
context.get("settings_hash") or "",
|
||
(perf_counter() - start) * 1000,
|
||
)
|
||
raise
|
||
logger.info(
|
||
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
|
||
step,
|
||
context.get("stat_date") or context.get("stat_dates") or "",
|
||
context.get("job_key") or "",
|
||
context.get("settings_hash") or "",
|
||
datetime.now().isoformat(),
|
||
(perf_counter() - start) * 1000,
|
||
)
|
||
return result
|
||
|
||
|
||
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
|
||
from datetime import datetime, timedelta
|
||
|
||
from app.gateway.admin_query_window import ADMIN_QUERY_WINDOW_DAYS
|
||
from deerflow.persistence.types import BEIJING_TZ
|
||
|
||
today = datetime.now(BEIJING_TZ).date()
|
||
start = today - timedelta(days=min(ADMIN_QUERY_WINDOW_DAYS, max(1, days)) - 1)
|
||
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
|
||
|
||
|
||
async def _leaderboard_snapshot_due_dates(
|
||
session_factory,
|
||
*,
|
||
settings_hash: str,
|
||
limit: int,
|
||
running_ttl_seconds: int,
|
||
) -> list[str]:
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
from sqlalchemy import and_, or_, select
|
||
|
||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||
|
||
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
|
||
async with session_factory() as session:
|
||
stmt = (
|
||
select(AdminLeaderboardDailyStatRow.stat_date)
|
||
.where(
|
||
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
||
or_(
|
||
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
|
||
and_(
|
||
AdminLeaderboardDailyStatRow.status == "running",
|
||
AdminLeaderboardDailyStatRow.updated_at < cutoff,
|
||
),
|
||
),
|
||
)
|
||
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
|
||
.limit(limit)
|
||
)
|
||
rows = (
|
||
await _execute_leaderboard_scheduler_query(
|
||
session,
|
||
"scheduler.due_dates",
|
||
stmt,
|
||
settings_hash=settings_hash,
|
||
)
|
||
).all()
|
||
return [str(row[0]) for row in rows]
|
||
|
||
|
||
async def _claim_leaderboard_snapshot(
|
||
session_factory,
|
||
*,
|
||
stat_date: str,
|
||
settings_hash: str,
|
||
running_ttl_seconds: int,
|
||
) -> bool:
|
||
from datetime import UTC, datetime
|
||
|
||
from app.gateway.routers.admin_users import _snapshot_is_fresh
|
||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||
from deerflow.persistence.types import BEIJING_TZ
|
||
|
||
async with session_factory() as session:
|
||
row = await _time_leaderboard_scheduler_db_operation(
|
||
"scheduler.claim.get",
|
||
lambda: session.get(
|
||
AdminLeaderboardDailyStatRow,
|
||
{"stat_date": stat_date, "settings_hash": settings_hash},
|
||
),
|
||
sql="SELECT admin_leaderboard_daily_stats by primary key",
|
||
stat_date=stat_date,
|
||
settings_hash=settings_hash,
|
||
)
|
||
if row is None or _snapshot_is_fresh(row, stat_date):
|
||
return False
|
||
if row.status == "running":
|
||
updated_at = row.updated_at
|
||
if isinstance(updated_at, datetime):
|
||
if updated_at.tzinfo is None:
|
||
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
|
||
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
|
||
if age < running_ttl_seconds:
|
||
return False
|
||
row.status = "running"
|
||
row.message = "?????????????"
|
||
row.error = None
|
||
row.updated_at = datetime.now(UTC)
|
||
await _time_leaderboard_scheduler_db_operation(
|
||
"scheduler.claim.commit",
|
||
lambda: session.commit(),
|
||
sql="COMMIT",
|
||
stat_date=stat_date,
|
||
settings_hash=settings_hash,
|
||
)
|
||
return True
|
||
|
||
|
||
async def _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash: str) -> bool:
|
||
"""Once per day after ``_LEADERBOARD_FINALIZE_HOUR``, force-recompute yesterday.
|
||
|
||
A ``ready`` past-day snapshot is otherwise treated as fresh forever and never
|
||
rebuilt. But the previous day's snapshot is usually generated before midnight,
|
||
so it can miss runs/tool-calls that only land in the DB around the day
|
||
boundary. This re-queues yesterday (``force=True``) a single time; the rebuild
|
||
stamps ``generated_at`` to today, which stops it from re-firing for the rest of
|
||
the day.
|
||
"""
|
||
from datetime import timedelta
|
||
|
||
from app.gateway.routers.admin_users import (
|
||
_as_beijing,
|
||
_queue_daily_leaderboard_snapshots,
|
||
)
|
||
from deerflow.persistence.admin_stats.finalize import should_finalize_previous_day
|
||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||
from deerflow.persistence.types import BEIJING_TZ
|
||
|
||
now = datetime.now(BEIJING_TZ)
|
||
yesterday = (now.date() - timedelta(days=1)).isoformat()
|
||
async with session_factory() as session:
|
||
row = await session.get(
|
||
AdminLeaderboardDailyStatRow,
|
||
{"stat_date": yesterday, "settings_hash": settings_hash},
|
||
)
|
||
generated_at = _as_beijing(row.generated_at) if (row is not None and row.generated_at is not None) else None
|
||
if not should_finalize_previous_day(
|
||
status=(row.status if row is not None else None),
|
||
generated_at=generated_at,
|
||
now=now,
|
||
finalize_hour=_LEADERBOARD_FINALIZE_HOUR,
|
||
):
|
||
return False
|
||
queued = await _queue_daily_leaderboard_snapshots(
|
||
session_factory,
|
||
[yesterday],
|
||
settings_hash,
|
||
force=True,
|
||
message="???????????????????????????",
|
||
)
|
||
if queued:
|
||
logger.info(
|
||
"Leaderboard snapshot finalize: queued previous day %s for full recompute (hash=%s)",
|
||
yesterday,
|
||
settings_hash[:10],
|
||
)
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
|
||
from app.gateway.routers.admin_users import (
|
||
_daily_job_key,
|
||
_queue_daily_leaderboard_snapshots,
|
||
_refresh_daily_leaderboard_snapshot,
|
||
_resolve_leaderboard_snapshot_scope,
|
||
)
|
||
from deerflow.config.system_settings import load_system_settings
|
||
from deerflow.persistence.engine import get_session_factory
|
||
|
||
session_factory = get_session_factory()
|
||
if session_factory is None:
|
||
return 0, 0
|
||
|
||
settings = load_system_settings().leaderboard
|
||
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
|
||
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
|
||
queued_dates = await _queue_daily_leaderboard_snapshots(
|
||
session_factory,
|
||
stat_dates,
|
||
settings_hash,
|
||
message="?????????????/?????",
|
||
)
|
||
|
||
# ??????? 1 ???????"???"????????????????????
|
||
try:
|
||
await _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash)
|
||
except Exception:
|
||
logger.exception("Leaderboard snapshot finalize check failed (non-fatal)")
|
||
|
||
due_dates = await _leaderboard_snapshot_due_dates(
|
||
session_factory,
|
||
settings_hash=settings_hash,
|
||
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
|
||
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
|
||
)
|
||
processed = 0
|
||
for stat_date in due_dates:
|
||
claimed = await _claim_leaderboard_snapshot(
|
||
session_factory,
|
||
stat_date=stat_date,
|
||
settings_hash=settings_hash,
|
||
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
|
||
)
|
||
if not claimed:
|
||
continue
|
||
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
|
||
await _refresh_daily_leaderboard_snapshot(
|
||
_daily_job_key(stat_date, settings_hash),
|
||
session_factory=session_factory,
|
||
settings=settings,
|
||
stat_date=stat_date,
|
||
settings_hash=settings_hash,
|
||
excluded=excluded,
|
||
include_scheduled=include_scheduled,
|
||
include_admins=include_admins,
|
||
include_failed=include_failed,
|
||
)
|
||
processed += 1
|
||
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
|
||
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
|
||
return processed, len(queued_dates)
|
||
|
||
|
||
async def _leaderboard_snapshot_scheduler_loop() -> None:
|
||
"""Background task inside the existing gateway process for daily snapshots."""
|
||
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
|
||
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
|
||
return
|
||
|
||
from deerflow.config.runtime_paths import runtime_home
|
||
|
||
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
|
||
if lock_handle is None:
|
||
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
|
||
return
|
||
|
||
logger.info(
|
||
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
|
||
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
|
||
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
|
||
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
|
||
)
|
||
try:
|
||
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
|
||
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
|
||
while True:
|
||
try:
|
||
processed, queued = await _run_leaderboard_snapshot_tick()
|
||
if processed or queued:
|
||
logger.info(
|
||
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
|
||
queued,
|
||
processed,
|
||
)
|
||
except Exception:
|
||
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
|
||
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
|
||
finally:
|
||
_release_singleton_lock(lock_handle)
|
||
|
||
|
||
async def _concurrency_sampler_loop(app: FastAPI) -> None:
|
||
"""Periodically record the in-flight run count for the admin pressure chart.
|
||
|
||
Runs in the gateway process, singleton-locked so only one worker samples.
|
||
Each tick records ``len(run_manager.list_active())`` and, roughly hourly,
|
||
purges samples older than the retention window. Best-effort throughout — a
|
||
failed tick is logged and the loop continues.
|
||
"""
|
||
if not _CONCURRENCY_SAMPLE_ENABLED:
|
||
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
|
||
return
|
||
|
||
store = getattr(app.state, "concurrency_sample_store", None)
|
||
run_mgr = getattr(app.state, "run_manager", None)
|
||
concurrency_gate = getattr(app.state, "concurrency_gate", None)
|
||
if store is None or run_mgr is None:
|
||
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
|
||
return
|
||
|
||
from deerflow.config.runtime_paths import runtime_home
|
||
|
||
lock_handle = _acquire_singleton_lock(runtime_home() / ".concurrency_sampler.lock")
|
||
if lock_handle is None:
|
||
logger.info("Concurrency sampler: another gateway worker holds the lock; this worker skips")
|
||
return
|
||
|
||
interval = max(1.0, _CONCURRENCY_SAMPLE_INTERVAL_SECONDS)
|
||
purge_every_ticks = max(1, int(3600 / interval)) # ~once per hour
|
||
logger.info("Concurrency sampler started: interval=%.1fs retention=%.1fh", interval, _CONCURRENCY_SAMPLE_RETENTION_HOURS)
|
||
ticks = 0
|
||
try:
|
||
while True:
|
||
try:
|
||
# Runtime toggle (admin can turn sampling off without a restart).
|
||
# mtime-cached read, so this is cheap every tick.
|
||
from deerflow.config.system_settings import get_concurrency_monitor_settings
|
||
|
||
if get_concurrency_monitor_settings().enabled:
|
||
active_count = None
|
||
if concurrency_gate is not None:
|
||
active_count = await concurrency_gate.count_active()
|
||
if active_count is None:
|
||
active = await run_mgr.list_active()
|
||
active_count = len(active)
|
||
await store.record(active_count)
|
||
ticks += 1
|
||
if ticks % purge_every_ticks == 0:
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
cutoff = datetime.now(UTC) - timedelta(hours=_CONCURRENCY_SAMPLE_RETENTION_HOURS)
|
||
await store.purge_older_than(cutoff)
|
||
except Exception:
|
||
logger.exception("Concurrency sampler tick failed (non-fatal)")
|
||
await asyncio.sleep(interval)
|
||
finally:
|
||
_release_singleton_lock(lock_handle)
|
||
|
||
|
||
async def _admin_audit_retention_loop(app: FastAPI) -> None:
|
||
"""Keep bounded admin-audit tables at the three-month retention target."""
|
||
from app.gateway.admin_audit_retention import sweep_admin_audit_retention
|
||
from app.gateway.admin_audit_retention_control import (
|
||
is_paused,
|
||
read_status,
|
||
write_status,
|
||
)
|
||
from app.gateway.admin_query_window import ADMIN_AUDIT_RETENTION_DAYS
|
||
from deerflow.config.runtime_paths import runtime_home
|
||
from deerflow.persistence.engine import get_session_factory
|
||
|
||
lock_handle = _acquire_singleton_lock(runtime_home() / ".admin_audit_retention.lock")
|
||
if lock_handle is None:
|
||
logger.info("Admin audit retention: another gateway worker holds the lock; this worker skips")
|
||
return
|
||
|
||
root = runtime_home()
|
||
status = read_status(root)
|
||
status.update(
|
||
{
|
||
"retention_days": ADMIN_AUDIT_RETENTION_DAYS,
|
||
"interval_seconds": _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS,
|
||
"startup_delay_seconds": _ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS,
|
||
"batch_size": _ADMIN_AUDIT_RETENTION_BATCH_SIZE,
|
||
"max_rounds": _ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE,
|
||
"max_sweep_seconds": _ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS,
|
||
}
|
||
)
|
||
|
||
def save_status(**updates: Any) -> None:
|
||
status.update(updates)
|
||
write_status(root, dict(status))
|
||
|
||
async def publish_progress(progress: dict[str, Any]) -> None:
|
||
await asyncio.to_thread(save_status, **progress)
|
||
|
||
logger.info(
|
||
"Admin audit retention started: interval=%ss startup_delay=%ss batch_size=%s "
|
||
"max_batches_per_store=%s max_sweep_seconds=%s",
|
||
_ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS,
|
||
_ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS,
|
||
_ADMIN_AUDIT_RETENTION_BATCH_SIZE,
|
||
_ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE,
|
||
_ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS,
|
||
)
|
||
try:
|
||
if _ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS:
|
||
# Do not compete with migrations, warmup, or users arriving just
|
||
# after a Gateway restart. The scheduled passes are deliberately
|
||
# maintenance work, not a startup requirement.
|
||
save_status(
|
||
status="scheduled",
|
||
current_source=None,
|
||
next_run_at=(
|
||
datetime.now().astimezone().timestamp()
|
||
+ _ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS
|
||
),
|
||
)
|
||
await asyncio.sleep(_ADMIN_AUDIT_RETENTION_STARTUP_DELAY_SECONDS)
|
||
while True:
|
||
if is_paused(root):
|
||
save_status(status="paused", current_source=None, next_run_at=None)
|
||
await asyncio.sleep(min(2.0, _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS))
|
||
continue
|
||
|
||
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||
save_status(
|
||
status="running",
|
||
current_source=None,
|
||
round=0,
|
||
deleted={
|
||
"llm_metrics": 0,
|
||
"tool_metrics": 0,
|
||
"run_summaries": 0,
|
||
"leaderboard_snapshots": 0,
|
||
},
|
||
started_at=started_at,
|
||
next_run_at=None,
|
||
last_error=None,
|
||
)
|
||
try:
|
||
result = await sweep_admin_audit_retention(
|
||
session_factory=get_session_factory(),
|
||
llm_metrics_store=getattr(app.state, "llm_metrics_store", None),
|
||
tool_metrics_store=getattr(app.state, "tool_metrics_store", None),
|
||
run_store=getattr(app.state, "run_store", None),
|
||
batch_size=_ADMIN_AUDIT_RETENTION_BATCH_SIZE,
|
||
max_batches_per_store=_ADMIN_AUDIT_RETENTION_MAX_BATCHES_PER_STORE,
|
||
inter_batch_pause_seconds=_ADMIN_AUDIT_RETENTION_INTER_BATCH_PAUSE_SECONDS,
|
||
max_sweep_seconds=_ADMIN_AUDIT_RETENTION_MAX_SWEEP_SECONDS,
|
||
should_stop=lambda: is_paused(root),
|
||
on_progress=publish_progress,
|
||
)
|
||
finished_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||
paused = is_paused(root)
|
||
last_sweep = {
|
||
"started_at": started_at,
|
||
"finished_at": finished_at,
|
||
"deleted": result,
|
||
"stopped_by_admin": paused,
|
||
}
|
||
save_status(
|
||
status="paused" if paused else "scheduled",
|
||
current_source=None,
|
||
round=status.get("round", 0),
|
||
deleted=result,
|
||
started_at=started_at,
|
||
finished_at=finished_at,
|
||
last_sweep=last_sweep,
|
||
next_run_at=None if paused else (
|
||
datetime.now().astimezone().timestamp()
|
||
+ _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS
|
||
),
|
||
)
|
||
except Exception:
|
||
save_status(
|
||
status="error",
|
||
current_source=None,
|
||
last_error="清理轮次发生异常,请查看后台日志",
|
||
finished_at=datetime.now().astimezone().isoformat(timespec="seconds"),
|
||
)
|
||
logger.exception("Admin audit retention sweep failed (non-fatal)")
|
||
|
||
# Wake periodically so an administrator can pause/resume without
|
||
# waiting for the full 15-minute cleanup interval.
|
||
remaining = _ADMIN_AUDIT_RETENTION_INTERVAL_SECONDS
|
||
while remaining > 0 and not is_paused(root):
|
||
sleep_for = min(2.0, remaining)
|
||
await asyncio.sleep(sleep_for)
|
||
remaining -= sleep_for
|
||
finally:
|
||
_release_singleton_lock(lock_handle)
|
||
|
||
|
||
def _warn_if_multi_worker() -> None:
|
||
"""?? uvicorn / gunicorn ? worker ???? ERROR ???
|
||
|
||
? agent / canvas / AI ??????? Gateway ????? ``StreamBridge``
|
||
?? run_id ? SSE ?????? map?? worker ????? worker A ??
|
||
``/runs/stream``???? worker B ? join/resume??? in-process map ??
|
||
???? 404 / ???
|
||
|
||
????? startup ? ERROR ????**???? fail-fast** ?? ??
|
||
???????????????? sticky session??????????
|
||
"""
|
||
# uvicorn / gunicorn ??????? worker ?????????????
|
||
# ?????? sibling ??????????? WEB_CONCURRENCY?gunicorn
|
||
# / uvicorn ????
|
||
web_concurrency = os.environ.get("WEB_CONCURRENCY", "").strip()
|
||
if web_concurrency and web_concurrency.isdigit() and int(web_concurrency) > 1:
|
||
logger.error(
|
||
"? ??? WEB_CONCURRENCY=%s?? worker ????AI ?? / ? agent "
|
||
"??????????????? worker ????????? / "
|
||
"SSE ?? / resume 400??????????? WEB_CONCURRENCY=1 "
|
||
"????????? sticky session??? AIWritingPage.tsx ????",
|
||
web_concurrency,
|
||
)
|
||
return
|
||
|
||
# ???? /proc ??? uvicorn ?????? Linux ??????
|
||
if not sys.platform.startswith("linux"):
|
||
return
|
||
try:
|
||
from pathlib import Path
|
||
|
||
peers = 0
|
||
for entry in Path("/proc").iterdir():
|
||
if not entry.name.isdigit():
|
||
continue
|
||
try:
|
||
cmdline = (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "ignore")
|
||
except OSError:
|
||
continue
|
||
if "uvicorn" in cmdline and "app.gateway.app" in cmdline:
|
||
peers += 1
|
||
if peers > 1:
|
||
logger.error(
|
||
"? ??? %d ??? uvicorn ???? worker ????? AI ?? "
|
||
"????? / SSE ??????????? worker ??????? "
|
||
"?? sticky session?",
|
||
peers,
|
||
)
|
||
except Exception:
|
||
# ????????? ?? ??????
|
||
pass
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||
"""Application lifespan handler."""
|
||
|
||
# ?????? worker ?? ?? ?? AI ?? / ? agent ???????
|
||
# ??????? worker ???????? ERROR ?????
|
||
_warn_if_multi_worker()
|
||
|
||
# Load config and check necessary environment variables at startup
|
||
try:
|
||
app.state.config = get_app_config()
|
||
apply_logging_level(app.state.config.log_level)
|
||
await configure_runtime_executor(app.state.config.runtime_performance)
|
||
logger.info("Configuration loaded successfully")
|
||
except Exception as e:
|
||
error_msg = f"Failed to load configuration during gateway startup: {e}"
|
||
logger.exception(error_msg)
|
||
raise RuntimeError(error_msg) from e
|
||
|
||
config = get_gateway_config()
|
||
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
|
||
|
||
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
|
||
async with langgraph_runtime(app):
|
||
logger.info("LangGraph runtime initialised")
|
||
|
||
# One-time repair switch for a failed, unused Workflow Studio schema.
|
||
# The helper is intentionally MySQL-only and drops an exact allow-list
|
||
# in the configured current database; it never expands ``workflow_%``.
|
||
# Keep this opt-in so established workflow data is never cleared during
|
||
# an ordinary restart.
|
||
if os.getenv("DEER_FLOW_REBUILD_WORKFLOW_SCHEMA", "").lower() in {"1", "true", "yes"}:
|
||
from deerflow.persistence.engine import rebuild_workflow_schema_for_mysql
|
||
|
||
logger.warning(
|
||
"DEER_FLOW_REBUILD_WORKFLOW_SCHEMA is enabled; rebuilding the unused workflow schema"
|
||
)
|
||
await rebuild_workflow_schema_for_mysql()
|
||
|
||
# Run Alembic migrations before anything else touches the schema.
|
||
await _run_db_migrations()
|
||
|
||
# The lightweight Wiki coordinator always runs: it upgrades persisted
|
||
# generation prompts and applies strict focus filters even when the
|
||
# optional local mirror is disabled. Mirror/index scans themselves
|
||
# remain opt-in inside the loop, with per-library database leases for
|
||
# multi-worker safety.
|
||
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
|
||
|
||
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
|
||
local_wiki_index_scheduler_loop(app)
|
||
)
|
||
|
||
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
|
||
try:
|
||
from deerflow.config.memory_config import load_recall_budget_cache
|
||
|
||
_mc_store = getattr(app.state, "memory_config_store", None)
|
||
if _mc_store is not None:
|
||
_recall_budgets = await _mc_store.load_all()
|
||
load_recall_budget_cache(_recall_budgets)
|
||
logger.info("Memory config cache warmed: %d user override(s)", len(_recall_budgets))
|
||
except Exception:
|
||
logger.exception("Memory config cache warm-up failed (non-fatal)")
|
||
|
||
# Ensure admin user exists (auto-create on first boot)
|
||
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
||
await _ensure_admin_user(app)
|
||
|
||
# Migrate "ownerless" on-disk skills into the new SkillStore table ?
|
||
# makes the multitenancy switch transparent for existing deployments.
|
||
catalog_skills = None
|
||
try:
|
||
catalog_skills = await _sync_legacy_skills(app)
|
||
except Exception:
|
||
logger.exception("Skill ownership migration failed (non-fatal)")
|
||
|
||
# Project the compact per-skill catalog metadata into MySQL before the
|
||
# Gateway accepts requests. List APIs read indexed scalar columns and
|
||
# never load skill markdown, display samples, or usage sidecars.
|
||
try:
|
||
from app.gateway.skill_catalog_cache import refresh_skill_catalog_projection
|
||
|
||
catalog_size = await refresh_skill_catalog_projection(
|
||
app.state.config,
|
||
skills=catalog_skills,
|
||
)
|
||
logger.info("Skill MySQL catalog projection synchronized: skills=%d", catalog_size)
|
||
except Exception:
|
||
logger.exception("Skill MySQL catalog projection sync failed (non-fatal)")
|
||
|
||
# ????? agent(intent / recommender / coordinator)???????
|
||
# ??? _sync_legacy_agents **??**??????????????????
|
||
# ??? upsert ? agents ?,?????? /api/agents ?????,
|
||
# ????????????????.deer-flow/ ? gitignore,??? clone
|
||
# ????????,seeder ????? _roundtable_seed_assets/ ??????
|
||
try:
|
||
from app.gateway.routers._roundtable_seed import (
|
||
ensure_roundtable_functional_agents,
|
||
)
|
||
|
||
ensure_roundtable_functional_agents()
|
||
except Exception:
|
||
logger.exception(
|
||
"Roundtable functional agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# AI ????? agent(researcher / outliner / writer / editor)???????
|
||
# ???:??? _sync_legacy_agents ??,?????????????
|
||
# upsert ? agents ?,admin ?????????????????? agent?
|
||
try:
|
||
from app.gateway.routers._ai_writing_seed import (
|
||
ensure_ai_writing_functional_agents,
|
||
)
|
||
|
||
ensure_ai_writing_functional_agents()
|
||
except Exception:
|
||
logger.exception(
|
||
"AI-writing functional agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# Strategy-selection functional agents (intent, six independent
|
||
# analysis angles, judge, and solution editor). Seed them before
|
||
# _sync_legacy_agents so they are immediately visible and editable in
|
||
# the existing agent-management UI on a fresh deployment.
|
||
try:
|
||
from app.gateway.routers._strategy_selection_seed import (
|
||
ensure_strategy_selection_agents,
|
||
)
|
||
|
||
ensure_strategy_selection_agents()
|
||
except Exception:
|
||
logger.exception(
|
||
"Strategy-selection functional agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 内置「模板数据转换器」agent(JSON → HTML 模板填充)。同样必须在
|
||
# _sync_legacy_agents **之前**补建目录,后者把它 upsert 进 agents 表,
|
||
# 第一次启动 /api/agents 与定时任务下拉框里就有它。
|
||
try:
|
||
from app.gateway.routers._template_builder_seed import (
|
||
ensure_template_builder_agent,
|
||
)
|
||
|
||
ensure_template_builder_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Template-builder agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# HTML 模板库:把随包内置模板 seed 进 {base_dir}/html-templates/,模板管理页
|
||
# 与定时任务下拉即可见(缺失才复制,绝不覆盖管理页/线上修改)。
|
||
try:
|
||
from deerflow.runtime.scheduler import template_store
|
||
|
||
template_store.ensure_seeded()
|
||
except Exception:
|
||
logger.exception(
|
||
"HTML template store seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 实验性「页面研报设计师」:在 _sync_legacy_agents 前补齐 agent 目录与
|
||
# 公共页面生成技能,首次启动即可在智能体管理页和普通对话中复用。
|
||
try:
|
||
from app.gateway.routers._page_report_designer_seed import (
|
||
ensure_page_report_designer_agent,
|
||
)
|
||
|
||
ensure_page_report_designer_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Page-report designer agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 深度研究「收集员」内置 agent(对话式信息收集)。同样必须在
|
||
# _sync_legacy_agents 之前补建目录,由后者 upsert 进 agents 表,
|
||
# 深度研究页的收集对话与智能体管理页即可直接使用。
|
||
try:
|
||
from app.gateway.routers._deep_research_seed import (
|
||
ensure_deep_research_collector_agent,
|
||
)
|
||
|
||
ensure_deep_research_collector_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Deep-research collector agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# Built-in public-document stack: agent + workflow skill + the full
|
||
# gongwen-assistant DOCX engine skill, all version-synced into
|
||
# skills/public/ (see _gongwen_seed.py). Seed before legacy agent
|
||
# sync so it is immediately available to ordinary conversations.
|
||
try:
|
||
from app.gateway.routers._gongwen_seed import ensure_gongwen_agent
|
||
|
||
ensure_gongwen_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Public-document agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 内置「认知态势图」public 技能:skills/ 不随 git 快照搬运,新机器
|
||
# 部署后技能缺失,由种子资产在首次启动时补建(目录存在则不动)。
|
||
try:
|
||
from app.gateway.routers._cognitive_situation_seed import (
|
||
ensure_cognitive_situation_skills,
|
||
)
|
||
|
||
ensure_cognitive_situation_skills()
|
||
except Exception:
|
||
logger.exception(
|
||
"Cognitive-situation skill seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# Workflow Studio owns a separate planning controller. It must be
|
||
# seeded before _sync_legacy_agents, so the planner is available to
|
||
# every user's candidate-workflow request on a fresh deployment.
|
||
try:
|
||
from app.gateway.routers._workflow_planner_seed import (
|
||
ensure_workflow_planner_agent,
|
||
)
|
||
|
||
ensure_workflow_planner_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Workflow planner agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 内置「强制检索输出助手」:必须在 _sync_legacy_agents 之前补建,
|
||
# 使首次启动就能在智能体管理/广场中使用。每轮检索门禁由 harness
|
||
# 的 ForcedResearchMiddleware 执行,而不是仅依赖 SOUL 提示词。
|
||
try:
|
||
from app.gateway.routers._forced_research_seed import (
|
||
ensure_forced_research_agent,
|
||
)
|
||
|
||
ensure_forced_research_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Forced-research responder agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 内置「查询变慢排查助手」:Linux + MySQL 间歇性查询变慢。目录已存在则不覆盖。
|
||
try:
|
||
from app.gateway.routers._query_slowdown_diagnosis_seed import (
|
||
ensure_query_slowdown_diagnosis_agent,
|
||
)
|
||
|
||
ensure_query_slowdown_diagnosis_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Query-slowdown diagnosis agent seeding failed at startup (non-fatal)"
|
||
)
|
||
# 内置「耗时排查助手」:根据执行记录和日志判断空等原因。目录已存在则不覆盖。
|
||
try:
|
||
from app.gateway.routers._latency_diagnosis_seed import (
|
||
ensure_latency_diagnosis_agent,
|
||
)
|
||
|
||
ensure_latency_diagnosis_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Latency-diagnosis agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 内置 Elasticsearch 离线运维助手:随离线包提供部署知识、只读采集脚本
|
||
# 和规则分析器。必须在 legacy agent 同步前补建,目录存在时不覆盖现场修改。
|
||
try:
|
||
from app.gateway.routers._elasticsearch_offline_ops_seed import (
|
||
ensure_elasticsearch_offline_ops_agent,
|
||
)
|
||
|
||
ensure_elasticsearch_offline_ops_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Elasticsearch offline operations agent seeding failed at startup (non-fatal)"
|
||
)
|
||
# 内置「任务研判报告助手」(agentfx 任务深链固定使用):同样必须在
|
||
# _sync_legacy_agents 之前补建目录 + 补建配套 task-report-import 技能
|
||
# (skills/ 被 .gitignore 排除),由后者 upsert 进 agents 表。
|
||
try:
|
||
from app.gateway.routers._agentfx_seed import ensure_agentfx_agent
|
||
|
||
ensure_agentfx_agent()
|
||
except Exception:
|
||
logger.exception(
|
||
"Agentfx analyst agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# 内置「任务分析总控」(任务分析定时的专用总控:无人值守分解任务并派活)。
|
||
try:
|
||
from app.gateway.routers._task_analysis_seed import (
|
||
ensure_task_analysis_functional_agents,
|
||
)
|
||
|
||
ensure_task_analysis_functional_agents()
|
||
except Exception:
|
||
logger.exception(
|
||
"Task analysis coordinator agent seeding failed at startup (non-fatal)"
|
||
)
|
||
|
||
# ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
|
||
try:
|
||
business_mapping_store = getattr(app.state, "business_mapping_store", None)
|
||
if business_mapping_store is not None:
|
||
inserted = await business_mapping_store.ensure_seed_defaults()
|
||
if inserted:
|
||
logger.info("Seeded %d business mapping rows", inserted)
|
||
except Exception:
|
||
logger.exception("Business mapping seeding failed at startup (non-fatal)")
|
||
|
||
# ???? .deer-flow/agents/{id}/ ???????? agent ?? agents ??
|
||
# ??????????????? sync(????? + N ? agent ??? DB
|
||
# SELECT/UPDATE),??????? + ?????? TTL ??,????
|
||
# /api/agents ?????
|
||
try:
|
||
from app.gateway.routers.agents import (
|
||
_mark_legacy_sync_fresh,
|
||
_sync_legacy_agents,
|
||
)
|
||
from app.gateway.services import _mark_legacy_sync_fresh as _services_mark_sync_fresh
|
||
|
||
agent_store = getattr(app.state, "agent_store", None)
|
||
if agent_store is not None:
|
||
await _sync_legacy_agents(agent_store)
|
||
_mark_legacy_sync_fresh()
|
||
# ``services.py`` keeps an independent TTL gate (used by
|
||
# ``start_run`` ? ``_enforce_agent_access``). Without this
|
||
# ack, the first ``/runs/stream`` after startup would
|
||
# re-run the heavy sync even though we just did it.
|
||
_services_mark_sync_fresh()
|
||
logger.info("Legacy folder-backed agents synced at startup")
|
||
# 内置功能型 agent(roundtable-* / ai-writing-*)按用途默认打标签,
|
||
# 供管理员「内置智能体」筛选按用途归类。幂等、只追加、失败不阻塞启动。
|
||
try:
|
||
from app.gateway.routers._builtin_agent_tag_seed import (
|
||
ensure_builtin_agent_purpose_tags,
|
||
)
|
||
|
||
tag_store = getattr(app.state, "tag_store", None)
|
||
if tag_store is not None:
|
||
await ensure_builtin_agent_purpose_tags(agent_store, tag_store)
|
||
except Exception:
|
||
logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)")
|
||
|
||
# Synchronize every worker's strategy registry only after the
|
||
# folder agents have been imported. In memory mode this also
|
||
# restores dynamic/default angle lifecycle from sidecars.
|
||
try:
|
||
await strategy_agents.ensure_strategy_agent_registry(app)
|
||
logger.info("Strategy agent registry synchronized for worker")
|
||
except Exception:
|
||
logger.exception("Strategy agent role seeding failed at startup (non-fatal)")
|
||
except Exception:
|
||
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
||
|
||
# Refresh after database initialization and all built-in skill seeders;
|
||
# import-time priming may have run before either was ready.
|
||
try:
|
||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||
|
||
await refresh_skills_system_prompt_cache_async()
|
||
except Exception:
|
||
logger.exception("Skills prompt cache warm-up failed at startup (non-fatal)")
|
||
|
||
# ?? skill curator ???:??????,?????????????
|
||
# (per-user interval_hours ??????????????)?
|
||
try:
|
||
app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
|
||
except Exception:
|
||
logger.exception("Skill curator scheduler failed to start (non-fatal)")
|
||
|
||
# ?? AI ????????:? config.yaml ? ai_writing.cleanup ???,
|
||
# ?????? 3 ????,? retention_days(?? 7 ?)?????,??
|
||
# ?? MySQL ??? + SQLite checkpointer ?? thread ???? worker
|
||
# ?????? leader,?????? worker ??????
|
||
try:
|
||
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
|
||
|
||
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
|
||
except Exception:
|
||
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
|
||
|
||
try:
|
||
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
|
||
except Exception:
|
||
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
|
||
|
||
# Concurrency (system-pressure) sampler — lightweight, singleton-locked,
|
||
# runtime-toggleable via system_settings.concurrency_monitor.enabled.
|
||
try:
|
||
app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
|
||
except Exception:
|
||
logger.exception("Concurrency sampler failed to start (non-fatal)")
|
||
|
||
try:
|
||
app.state.admin_audit_retention_task = asyncio.create_task(
|
||
_admin_audit_retention_loop(app)
|
||
)
|
||
except Exception:
|
||
logger.exception("Admin audit retention scheduler failed to start (non-fatal)")
|
||
|
||
# Start IM channel service if any channels are configured
|
||
try:
|
||
from app.channels.service import start_channel_service
|
||
|
||
channel_service = await start_channel_service(app.state.config)
|
||
logger.info("Channel service started: %s", channel_service.get_status())
|
||
except Exception:
|
||
logger.exception("No IM channels configured or channel service failed to start")
|
||
|
||
scheduled_tasks_enabled = os.environ.get("SCHEDULED_TASKS_ENABLED", "true").strip().lower()
|
||
if scheduled_tasks_enabled in {"0", "false", "no", "off"}:
|
||
logger.info("Scheduled task service disabled by SCHEDULED_TASKS_ENABLED=%s", scheduled_tasks_enabled)
|
||
else:
|
||
try:
|
||
scheduled_task_store = getattr(app.state, "scheduled_task_store", None)
|
||
if scheduled_task_store is not None:
|
||
scheduler_service = ScheduledTaskService(app, scheduled_task_store)
|
||
app.state.scheduled_task_service = scheduler_service
|
||
set_scheduled_task_service(scheduler_service)
|
||
await scheduler_service.start()
|
||
logger.info("Scheduled task service started")
|
||
except Exception:
|
||
logger.exception("Scheduled task service failed to start")
|
||
|
||
# External-task chain-binding scheduler (任务分析定时)。
|
||
task_analysis_enabled = os.environ.get("TASK_ANALYSIS_ENABLED", "true").strip().lower()
|
||
if task_analysis_enabled in {"0", "false", "no", "off"}:
|
||
logger.info("Task analysis service disabled by TASK_ANALYSIS_ENABLED")
|
||
else:
|
||
try:
|
||
from deerflow.runtime.task_analysis import TaskAnalysisService
|
||
|
||
task_analysis_store = getattr(app.state, "task_analysis_store", None)
|
||
if task_analysis_store is not None:
|
||
ta_service = TaskAnalysisService(app, task_analysis_store)
|
||
app.state.task_analysis_service = ta_service
|
||
await ta_service.start()
|
||
logger.info("Task analysis service started")
|
||
except Exception:
|
||
logger.exception("Task analysis service failed to start")
|
||
|
||
if getattr(app.state, "worker_pool", None) is not None:
|
||
app.state.worker_pool.start()
|
||
yield
|
||
|
||
artifact_library_tasks = list(
|
||
getattr(app.state, "artifact_library_sync_tasks", {}).values()
|
||
)
|
||
for task in artifact_library_tasks:
|
||
task.cancel()
|
||
if artifact_library_tasks:
|
||
await asyncio.gather(*artifact_library_tasks, return_exceptions=True)
|
||
|
||
llmwiki_index_scheduler_task = getattr(app.state, "llmwiki_index_scheduler_task", None)
|
||
if llmwiki_index_scheduler_task is not None:
|
||
llmwiki_index_scheduler_task.cancel()
|
||
try:
|
||
await llmwiki_index_scheduler_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception:
|
||
logger.exception("Local Wiki index scheduler shutdown error")
|
||
llmwiki_index_jobs = list(getattr(app.state, "llmwiki_index_jobs", set()))
|
||
for job in llmwiki_index_jobs:
|
||
job.cancel()
|
||
if llmwiki_index_jobs:
|
||
await asyncio.gather(*llmwiki_index_jobs, return_exceptions=True)
|
||
|
||
# ?? curator ???
|
||
curator_task = getattr(app.state, "curator_scheduler_task", None)
|
||
if curator_task is not None:
|
||
curator_task.cancel()
|
||
try:
|
||
await curator_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception:
|
||
logger.exception("Curator scheduler shutdown error")
|
||
|
||
# ?? AI ???????
|
||
cleanup_task = getattr(app.state, "ai_writing_cleanup_task", None)
|
||
if cleanup_task is not None:
|
||
cleanup_task.cancel()
|
||
try:
|
||
await cleanup_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception:
|
||
logger.exception("AI writing cleanup scheduler shutdown error")
|
||
|
||
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
|
||
if leaderboard_snapshot_task is not None:
|
||
leaderboard_snapshot_task.cancel()
|
||
try:
|
||
await leaderboard_snapshot_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception:
|
||
logger.exception("Leaderboard snapshot scheduler shutdown error")
|
||
|
||
concurrency_sampler_task = getattr(app.state, "concurrency_sampler_task", None)
|
||
if concurrency_sampler_task is not None:
|
||
concurrency_sampler_task.cancel()
|
||
try:
|
||
await concurrency_sampler_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception:
|
||
logger.exception("Concurrency sampler shutdown error")
|
||
|
||
admin_audit_retention_task = getattr(app.state, "admin_audit_retention_task", None)
|
||
if admin_audit_retention_task is not None:
|
||
admin_audit_retention_task.cancel()
|
||
try:
|
||
await admin_audit_retention_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception:
|
||
logger.exception("Admin audit retention scheduler shutdown error")
|
||
|
||
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
|
||
# thread ??? LangGraph Server ? checkpointer ???lifespan ???
|
||
# ``langgraph_runtime()`` ? contextmanager ???????????????
|
||
|
||
try:
|
||
scheduler_service = getattr(app.state, "scheduled_task_service", None)
|
||
if scheduler_service is not None:
|
||
await scheduler_service.stop()
|
||
except Exception:
|
||
logger.exception("Failed to stop scheduled task service")
|
||
finally:
|
||
set_scheduled_task_service(None)
|
||
|
||
try:
|
||
ta_service = getattr(app.state, "task_analysis_service", None)
|
||
if ta_service is not None:
|
||
await ta_service.stop()
|
||
except Exception:
|
||
logger.exception("Failed to stop task analysis service")
|
||
|
||
# Stop channel service on shutdown (bounded to prevent worker hang)
|
||
try:
|
||
from app.channels.service import stop_channel_service
|
||
|
||
await asyncio.wait_for(
|
||
stop_channel_service(),
|
||
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
||
)
|
||
except TimeoutError:
|
||
logger.warning(
|
||
"Channel service shutdown exceeded %.1fs; proceeding with worker exit.",
|
||
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
||
)
|
||
except Exception:
|
||
logger.exception("Failed to stop channel service")
|
||
|
||
logger.info("Shutting down API Gateway")
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||
from app.gateway.worker_pool.lifecycle import managed_controller
|
||
from app.gateway.perf_logging import event_loop_watchdog, register_stack_dump_signal
|
||
|
||
register_stack_dump_signal()
|
||
watchdog = asyncio.create_task(event_loop_watchdog(app), name="gateway-event-loop-watchdog")
|
||
try:
|
||
async with managed_controller(app):
|
||
async with _gateway_lifespan(app):
|
||
yield
|
||
finally:
|
||
watchdog.cancel()
|
||
await asyncio.gather(watchdog, return_exceptions=True)
|
||
from app.gateway.api_read_cache import close_api_read_cache
|
||
|
||
await close_api_read_cache()
|
||
|
||
|
||
def create_app() -> FastAPI:
|
||
"""Create and configure the FastAPI application.
|
||
|
||
Returns:
|
||
Configured FastAPI application instance.
|
||
"""
|
||
config = get_gateway_config()
|
||
logger.info("API documentation endpoints enabled: %s", config.enable_docs)
|
||
docs_kwargs = {
|
||
"docs_url": None,
|
||
"redoc_url": None,
|
||
"openapi_url": "/openapi.json",
|
||
} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||
|
||
app = FastAPI(
|
||
title="DeerFlow API Gateway",
|
||
description="""
|
||
## DeerFlow API Gateway
|
||
|
||
API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execution capabilities.
|
||
|
||
### Features
|
||
|
||
- **Models Management**: Query and retrieve available AI models
|
||
- **MCP Configuration**: Manage Model Context Protocol (MCP) server configurations
|
||
- **Memory Management**: Access and manage global memory data for personalized conversations
|
||
- **Skills Management**: Query and manage skills and their enabled status
|
||
- **Artifacts**: Access thread artifacts and generated files
|
||
- **Health Monitoring**: System health check endpoints
|
||
|
||
### Architecture
|
||
|
||
LangGraph requests are handled by nginx reverse proxy.
|
||
This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts.
|
||
""",
|
||
version="0.1.0",
|
||
lifespan=lifespan,
|
||
**docs_kwargs,
|
||
openapi_tags=[
|
||
{
|
||
"name": "models",
|
||
"description": "Operations for querying available AI models and their configurations",
|
||
},
|
||
{
|
||
"name": "mcp",
|
||
"description": "Manage Model Context Protocol (MCP) server configurations",
|
||
},
|
||
{
|
||
"name": "memory",
|
||
"description": "Access and manage global memory data for personalized conversations",
|
||
},
|
||
{
|
||
"name": "skills",
|
||
"description": "Manage skills and their configurations",
|
||
},
|
||
{
|
||
"name": "artifacts",
|
||
"description": "Access and download thread artifacts and generated files",
|
||
},
|
||
{
|
||
"name": "uploads",
|
||
"description": "Upload and manage user files for threads",
|
||
},
|
||
{
|
||
"name": "threads",
|
||
"description": "Manage DeerFlow thread-local filesystem data",
|
||
},
|
||
{
|
||
"name": "agents",
|
||
"description": "Create and manage custom agents with per-agent config and prompts",
|
||
},
|
||
{
|
||
"name": "suggestions",
|
||
"description": "Generate follow-up question suggestions for conversations",
|
||
},
|
||
{
|
||
"name": "channels",
|
||
"description": "Manage IM channel integrations (Feishu, Slack, Telegram)",
|
||
},
|
||
{
|
||
"name": "assistants-compat",
|
||
"description": "LangGraph Platform-compatible assistants API (stub)",
|
||
},
|
||
{
|
||
"name": "runs",
|
||
"description": "LangGraph Platform-compatible runs lifecycle (create, stream, cancel)",
|
||
},
|
||
{
|
||
"name": "scheduled-tasks",
|
||
"description": "Create, manage, and execute user scheduled agent tasks",
|
||
},
|
||
{
|
||
"name": "health",
|
||
"description": "Health check and system status endpoints",
|
||
},
|
||
],
|
||
)
|
||
|
||
@app.middleware("http")
|
||
async def invalidate_catalog_cache_after_write(request: Request, call_next):
|
||
response = await call_next(request)
|
||
if request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and response.status_code < 400:
|
||
path = request.url.path.rstrip("/")
|
||
collections: set[str] = set()
|
||
if path == "/api/agents" or path.startswith("/api/agents/"):
|
||
collections.update(("agents", "tags"))
|
||
if path == "/api/skills" or path.startswith("/api/skills/"):
|
||
collections.update(("skills", "tags"))
|
||
if path == "/api/tags" or path.startswith("/api/tags/"):
|
||
collections.update(("agents", "skills", "recommended_questions", "light_apps", "tags"))
|
||
if path == "/api/positions" or path.startswith("/api/positions/"):
|
||
collections.update(("agents", "skills", "recommended_questions"))
|
||
if path == "/api/user-preferences" or path.startswith("/api/user-preferences/"):
|
||
collections.add("user_preferences")
|
||
if path == "/api/user-prompts" or path.startswith("/api/user-prompts/"):
|
||
collections.add("user_prompts")
|
||
if path.startswith("/api/recommended-questions"):
|
||
collections.update(("recommended_questions", "tags"))
|
||
if path == "/api/scheduled-tasks" or path.startswith("/api/scheduled-tasks/"):
|
||
collections.add("tags")
|
||
if path == "/api/light-apps" or path.startswith("/api/light-apps/"):
|
||
collections.add("tags")
|
||
if path == "/api/sensitive-words":
|
||
collections.add("sensitive_words")
|
||
config = getattr(request.app.state, "config", None)
|
||
if config is not None and collections:
|
||
from app.gateway.api_read_cache import invalidate_cached_collection
|
||
|
||
await asyncio.gather(
|
||
*(
|
||
invalidate_cached_collection(
|
||
config,
|
||
collection,
|
||
force=True,
|
||
)
|
||
for collection in collections
|
||
)
|
||
)
|
||
return response
|
||
|
||
if config.enable_docs:
|
||
# Keep these paths relative to the page URL. Nginx strips the
|
||
# /deerflow deployment prefix before forwarding to this app, and
|
||
# root-relative URLs would otherwise escape that prefix in browsers.
|
||
app.mount(
|
||
"/docs/static",
|
||
StaticFiles(directory=_DOCS_STATIC_DIR),
|
||
name="docs-static",
|
||
)
|
||
|
||
@app.get("/docs", include_in_schema=False)
|
||
async def swagger_ui() -> HTMLResponse:
|
||
return get_swagger_ui_html(
|
||
openapi_url="./openapi.json",
|
||
title=f"{app.title} - Swagger UI",
|
||
swagger_js_url="./docs/static/swagger-ui-bundle.js",
|
||
swagger_css_url="./docs/static/swagger-ui.css",
|
||
swagger_favicon_url="./docs/static/favicon-32x32.png",
|
||
swagger_ui_parameters=app.swagger_ui_parameters,
|
||
)
|
||
|
||
@app.get("/redoc", include_in_schema=False)
|
||
async def redoc() -> HTMLResponse:
|
||
return get_redoc_html(
|
||
openapi_url="./openapi.json",
|
||
title=f"{app.title} - ReDoc",
|
||
redoc_js_url="./docs/static/redoc.standalone.js",
|
||
redoc_favicon_url="./docs/static/favicon-32x32.png",
|
||
with_google_fonts=False,
|
||
)
|
||
|
||
# Auth: reject unauthenticated requests to non-public paths (fail-closed safety net)
|
||
from app.gateway.worker_pool.proxy import WorkerProxy
|
||
|
||
app.add_middleware(WorkerProxy)
|
||
app.add_middleware(AuthMiddleware)
|
||
|
||
# CSRF: Double Submit Cookie pattern for state-changing requests
|
||
app.add_middleware(CSRFMiddleware)
|
||
|
||
# CORS
|
||
if is_auth_disabled():
|
||
logger.warning(
|
||
"DEER_FLOW_AUTH_DISABLED is set ? API authentication is bypassed. "
|
||
"Do not use this in production."
|
||
)
|
||
|
||
def _cors_allow_any_origin() -> bool:
|
||
"""Mirror any browser Origin (works with credentials); cannot use literal '*'."""
|
||
v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
|
||
return v in ("1", "true", "yes", "on") or is_auth_disabled()
|
||
|
||
# Keep standard local frontend origins available even when the startup
|
||
# script supplies an explicit production/development allow-list. Vite can
|
||
# choose 5173 or move to 5174 when that port is already occupied; excluding
|
||
# the former makes every browser preflight fail after a backend restart.
|
||
local_vite_origins = [
|
||
"http://localhost:5173",
|
||
"http://127.0.0.1:5173",
|
||
"http://localhost:5174",
|
||
"http://127.0.0.1:5174",
|
||
"http://localhost:3000",
|
||
"http://127.0.0.1:3000",
|
||
]
|
||
|
||
# The legacy TaskCOP Vue application uses its own Vite development port.
|
||
taskcop_vite_origins = [
|
||
"http://localhost:8080",
|
||
"http://127.0.0.1:8080",
|
||
]
|
||
local_dev_origins = [*local_vite_origins, *taskcop_vite_origins]
|
||
|
||
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
|
||
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
|
||
if cors_origins:
|
||
cors_origins = list(dict.fromkeys([*cors_origins, *local_dev_origins]))
|
||
|
||
# Headers the LangGraph SDK / browser clients need to read from cross-origin
|
||
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
|
||
# null on cross-origin streaming responses, breaking run-id extraction and reconnect.
|
||
cors_expose_headers = [
|
||
"Content-Disposition",
|
||
"Content-Location",
|
||
"Location",
|
||
"X-Run-Id",
|
||
"X-Thread-Id",
|
||
]
|
||
|
||
# Public pages (e.g. http://47.x.x.x:7010) calling this Gateway on localhost
|
||
# trigger Chrome/Edge Private Network Access preflights. Without
|
||
# allow_private_network=True the browser reports a generic CORS failure even
|
||
# when Access-Control-Allow-Origin is correct.
|
||
cors_kwargs = {
|
||
"allow_credentials": True,
|
||
"allow_methods": ["*"],
|
||
"allow_headers": ["*"],
|
||
"expose_headers": cors_expose_headers,
|
||
"allow_private_network": True,
|
||
}
|
||
|
||
if _cors_allow_any_origin():
|
||
logger.warning(
|
||
"Permissive CORS enabled ? any Origin is echoed back (GATEWAY_CORS_ALLOW_ALL or "
|
||
"DEER_FLOW_AUTH_DISABLED). Do not expose publicly."
|
||
)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=[],
|
||
# fullmatch() on the Origin header; browsers send scheme://host[:port]
|
||
allow_origin_regex=r".+",
|
||
**cors_kwargs,
|
||
)
|
||
elif cors_origins:
|
||
if "*" in cors_origins:
|
||
logger.error(
|
||
"GATEWAY_CORS_ORIGINS contains '*' with allow_credentials=True ? invalid. "
|
||
"Set GATEWAY_CORS_ALLOW_ALL=1 instead, or list explicit origins."
|
||
)
|
||
cors_origins = [o for o in cors_origins if o != "*"]
|
||
if cors_origins:
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=cors_origins,
|
||
**cors_kwargs,
|
||
)
|
||
else:
|
||
# Safe-by-default fallback for local frontend development. Without any CORS
|
||
# middleware, browser preflight OPTIONS requests will hit routes directly and
|
||
# fail with 405, which blocks login and all JSON POST APIs.
|
||
dev_origins = local_dev_origins
|
||
logger.warning(
|
||
"No CORS configuration found; enabling local dev origins only: %s. "
|
||
"Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
|
||
", ".join(dev_origins),
|
||
)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=dev_origins,
|
||
**cors_kwargs,
|
||
)
|
||
|
||
# This one anonymous API is intentionally callable by browser code from
|
||
# every origin. Keep the override route-scoped so authenticated APIs retain
|
||
# the deployment's normal CORS allow-list.
|
||
app.add_middleware(PublicKnowledgeCORSMiddleware)
|
||
|
||
# Include routers
|
||
# Models API is mounted at /api/models
|
||
app.include_router(models.router)
|
||
# Administrator-managed config.yaml model settings and connection tests.
|
||
app.include_router(model_management.router)
|
||
|
||
# MCP API is mounted at /api/mcp
|
||
app.include_router(mcp.router)
|
||
|
||
# DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
|
||
app.include_router(llmwiki.router)
|
||
app.include_router(llmwiki_index.router)
|
||
app.include_router(external_llmwiki.router)
|
||
|
||
# Memory API is mounted at /api/memory
|
||
app.include_router(memory.router)
|
||
|
||
# Skills API is mounted at /api/skills
|
||
app.include_router(skills.router)
|
||
# Admin skill distillation matrix + global assistant knowledge base.
|
||
app.include_router(skill_knowledge.router)
|
||
app.include_router(assistant_knowledge.router)
|
||
app.include_router(assistant_knowledge.export_router)
|
||
from app.gateway.routers import wiki_packages
|
||
|
||
app.include_router(wiki_packages.router)
|
||
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
||
app.include_router(artifacts.router)
|
||
|
||
# Web App site artifacts (multi-page site / flowchart / map) with signed
|
||
# public static hosting at /api/public/site-preview/{token}/
|
||
app.include_router(web_app_sites.router)
|
||
|
||
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
||
app.include_router(uploads.router)
|
||
# Global database-backed upload limits at /api/upload-policy.
|
||
app.include_router(upload_policy.router)
|
||
|
||
# Optional Chrome-extension page capture API is mounted at /api/browser-context
|
||
app.include_router(browser_context.router)
|
||
|
||
# Thread cleanup API is mounted at /api/threads/{thread_id}
|
||
from app.gateway.routers import analysis_projects
|
||
|
||
app.include_router(analysis_projects.router)
|
||
app.include_router(threads.router)
|
||
# Dedicated taskId + goPath history lookup for single-agent task deep-link chats.
|
||
app.include_router(task_deeplink_threads.router)
|
||
app.include_router(task_deeplink_details.router)
|
||
# Agents API is mounted at /api/agents
|
||
app.include_router(agents.router)
|
||
app.include_router(strategy_agents.router)
|
||
|
||
# Recommended Questions API is mounted at /api/recommended-questions
|
||
app.include_router(recommended_questions.router)
|
||
|
||
app.include_router(user_prompts.router)
|
||
|
||
# Per-user UI preferences at /api/user-preferences
|
||
app.include_router(user_preferences.router)
|
||
|
||
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
|
||
app.include_router(roundtable_drafts.router)
|
||
|
||
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
|
||
app.include_router(roundtable_task_drafts.router)
|
||
|
||
# Task-scoped artifact submission status; stores references only, never artifact content.
|
||
app.include_router(roundtable_artifact_submissions.router)
|
||
|
||
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
||
app.include_router(flow_extract.router)
|
||
|
||
# Roundtable-planning business chains at /api/roundtable-chains
|
||
app.include_router(roundtable_chains.router)
|
||
|
||
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
|
||
# Canonical position-workspace persistence API.
|
||
app.include_router(position_roundtable.router, prefix="/api")
|
||
# Compatibility route for intranet gateways whose allowlist only exposes
|
||
# the established `/api/multi-agent/*` namespace.
|
||
app.include_router(
|
||
position_roundtable.router,
|
||
prefix="/api/multi-agent",
|
||
include_in_schema=False,
|
||
)
|
||
|
||
# Global business ? agent / chain mapping at /api/business-mapping
|
||
app.include_router(business_mapping.router)
|
||
|
||
# Position-roundtable work-view role configuration at /api/position-roles.
|
||
app.include_router(position_roles.router)
|
||
|
||
# Light-app registry (?????) at /api/light-apps
|
||
app.include_router(light_apps.router)
|
||
|
||
# Workflow Studio: definitions / runs / SSE / data sources / embed / Coze compat.
|
||
# Static prefixes (node-types, resources, planning, runs, data-sources, embed) must be
|
||
# registered before the /{workflow_id} catch-alls in workflows.router.
|
||
app.include_router(workflow_resources.router)
|
||
app.include_router(workflow_data_sources.router)
|
||
app.include_router(workflow_embed.router)
|
||
app.include_router(workflow_planning.router)
|
||
app.include_router(workflow_runs.router)
|
||
app.include_router(workflow_studio.router)
|
||
app.include_router(workflows.router)
|
||
app.include_router(workflows_coze_compat.router)
|
||
app.include_router(workflows_coze_compat.chrome_router)
|
||
|
||
# Sidebar-menu overrides (菜单管理) at /api/menu-overrides
|
||
app.include_router(menu_overrides.router)
|
||
|
||
# Compact/sidebar-summary menu overrides at /api/compact-menu-overrides
|
||
app.include_router(compact_menu_overrides.router)
|
||
|
||
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
|
||
app.include_router(sensitive_words.router)
|
||
|
||
# Public-document workbench: durable originals, review snapshots and DOCX versions.
|
||
app.include_router(gongwen_documents.router)
|
||
|
||
# Sentiment-analysis virtual agent AG-UI SSE proxy at /api/sentiment-agent
|
||
app.include_router(sentiment_agent.router)
|
||
|
||
# Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
|
||
app.include_router(skill_addresses.router)
|
||
|
||
# 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
|
||
app.include_router(dashboard_sessions.router)
|
||
|
||
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||
app.include_router(task_buttons.router)
|
||
|
||
# Admin exact-match question/answer short-circuit at /api/fixed-questions
|
||
app.include_router(fixed_questions.router)
|
||
|
||
# AgentScope report-collaboration workbench at /api/report-collaboration
|
||
app.include_router(report_collaboration.router)
|
||
|
||
# Deep-research report structures (工作流配置 / 报告结构) at /api/report-structures
|
||
app.include_router(report_structures.router)
|
||
|
||
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
||
app.include_router(taskcop_tasks.router)
|
||
|
||
# Task-scoped situation-report details used by dashboard drawing / task gates.
|
||
app.include_router(task_reports.router)
|
||
|
||
# Force-situation task detail documents (new task type; free-form JSON).
|
||
app.include_router(force_situation.router)
|
||
|
||
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
|
||
app.include_router(parallel_agents.router)
|
||
app.include_router(parallel_agent_tasks.router)
|
||
|
||
# Roundtable-planning background jobs at /api/roundtable-jobs
|
||
app.include_router(roundtable_jobs.router)
|
||
app.include_router(roundtable_rooms.router)
|
||
app.include_router(roundtable_diagnostics.router)
|
||
|
||
# Deep Research (深度研究) API at /api/deep-research
|
||
app.include_router(deep_research.router)
|
||
|
||
# The independent enterprise-research workbench is temporarily disabled.
|
||
# Do not mount /api/enterprise-research while its MySQL schema is inactive.
|
||
|
||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||
app.include_router(suggestions.router)
|
||
|
||
# Writing rewrite API is mounted at /api/writing
|
||
app.include_router(writing.router)
|
||
|
||
# AI Writing multi-agent API is mounted at /api/ai-writing
|
||
app.include_router(ai_writing.router)
|
||
|
||
# Scheduled task API is mounted at /api/scheduled-tasks
|
||
app.include_router(scheduled_tasks.router)
|
||
|
||
# External-task multi-agent scheduled analysis (/api/task-analysis)
|
||
app.include_router(task_analysis.router)
|
||
app.include_router(task_analysis_qa.router)
|
||
|
||
# Public, unauthenticated read-only scheduled task API
|
||
# (/api/public/scheduled-tasks/*). Lets external systems query tasks and
|
||
# surface results without a DeerFlow login.
|
||
app.include_router(public_scheduled_tasks.router)
|
||
|
||
# Public read-only user leaderboard publish page (/api/public/leaderboard)
|
||
app.include_router(public_leaderboard.router)
|
||
|
||
# Public embed Q&A status polling (/api/public/embed/*)
|
||
app.include_router(public_embed.router)
|
||
app.include_router(public_config.router)
|
||
app.include_router(html_runtime.router)
|
||
app.include_router(html_images.router)
|
||
app.include_router(html_page_favorites.router)
|
||
# HTML 模板管理(模板管理页;读开放,写仅 admin)。
|
||
app.include_router(html_templates.router)
|
||
|
||
# Product knowledge base API is mounted at /api/knowledge
|
||
app.include_router(knowledge.router)
|
||
# Anonymous, section-level vector search over every published Wiki base
|
||
# except the system conversation-deposit base.
|
||
app.include_router(public_knowledge_vector_search.router)
|
||
|
||
# External knowledge ingest (publish md artifacts) at /api/knowledge-ingest
|
||
app.include_router(knowledge_ingest.router)
|
||
|
||
# System settings API is mounted at /api/system-settings
|
||
app.include_router(system_settings.router)
|
||
app.include_router(app_identity.router)
|
||
app.include_router(app_identity.public_router)
|
||
from app.gateway.worker_pool.router import router as worker_pool_router
|
||
|
||
app.include_router(worker_pool_router)
|
||
|
||
# Admin LLM metrics audit + export
|
||
app.include_router(llm_metrics.router)
|
||
|
||
# Admin window into other users' threads / messages / memory
|
||
app.include_router(admin_users.router)
|
||
app.include_router(admin_users.self_router)
|
||
app.include_router(admin_audit_retention_routes.router)
|
||
|
||
app.include_router(admin_active_runs.router)
|
||
app.include_router(skill_view_diagnostics.router)
|
||
app.include_router(model_exchanges.router)
|
||
|
||
# Admin tool / skill call metrics audit + export
|
||
app.include_router(tool_metrics.router)
|
||
|
||
# Admin model-stream truncation event log
|
||
app.include_router(stream_truncations.router)
|
||
|
||
# Channels API is mounted at /api/channels
|
||
app.include_router(channels.router)
|
||
|
||
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
|
||
app.include_router(checkpoint_migration.router)
|
||
# Admin monthly archive/rollback for PostgreSQL chat checkpoints.
|
||
app.include_router(chat_archive.router)
|
||
|
||
# Assistants compatibility API (LangGraph Platform stub)
|
||
app.include_router(assistants_compat.router)
|
||
|
||
# Auth API is mounted at /api/v1/auth
|
||
app.include_router(auth.router)
|
||
app.include_router(service_tokens.router)
|
||
|
||
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
||
app.include_router(feedback.router)
|
||
# Aggregate user-feedback answer quality (not a complete capability score).
|
||
app.include_router(feedback.agent_score_router)
|
||
|
||
# Notifications API is mounted at /api/notifications
|
||
app.include_router(notifications.router)
|
||
|
||
# Tags API is mounted at /api/tags
|
||
app.include_router(tags.router)
|
||
|
||
# 岗位 (position) management API is mounted at /api/positions
|
||
app.include_router(positions.router)
|
||
|
||
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||
app.include_router(thread_runs.router)
|
||
|
||
app.include_router(thread_shares.router)
|
||
# Public, unauthenticated read-only share snapshots (/api/public/shares/*).
|
||
app.include_router(thread_shares.public_router)
|
||
|
||
# ?????????import ???? / view ????????
|
||
app.include_router(roundtable_draft_shares.router)
|
||
# Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*).
|
||
app.include_router(roundtable_draft_shares.public_router)
|
||
|
||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||
app.include_router(runs.router)
|
||
|
||
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
|
||
app.include_router(open_chat.router)
|
||
|
||
# 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能
|
||
app.include_router(direct_chat.router)
|
||
|
||
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
|
||
app.include_router(open_3qfx.router)
|
||
|
||
# --- ?????????? /page/workspace/roundtable/planning?---
|
||
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
||
# ??? frontend-web/docs/multi-agent-backend-dev.md
|
||
app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
|
||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||
|
||
# Health probes are always unauthenticated (see
|
||
# auth_middleware.PUBLIC_HEALTH_PATHS). Support the bare Gateway path,
|
||
# API base URL, and LangGraph proxy base URL for deployment monitors.
|
||
@app.api_route("/health", methods=["GET", "HEAD"], tags=["health"])
|
||
@app.api_route("/api/health", methods=["GET", "HEAD"], tags=["health"])
|
||
@app.api_route("/api/langgraph/health", methods=["GET", "HEAD"], tags=["health"])
|
||
async def health_check() -> dict:
|
||
"""Health check endpoint.
|
||
|
||
Returns:
|
||
Service health status information.
|
||
"""
|
||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||
|
||
# WeKnora's embedded frontend uses root-relative routes such as /platform,
|
||
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
|
||
# true fallback and cannot intercept DeerFlow APIs.
|
||
# Register the public-prefix routes before the unprefixed static fallback.
|
||
# Otherwise proxy_router's /{proxied_path:path} route consumes every
|
||
# /deerflow/* request before the prefixed platform/assets routes can match.
|
||
app.include_router(llmwiki.proxy_router, prefix="/deerflow")
|
||
app.include_router(llmwiki.proxy_router)
|
||
|
||
from app.gateway.worker_pool.telemetry import RequestTelemetryMiddleware
|
||
app.add_middleware(RequestTelemetryMiddleware)
|
||
|
||
return app
|
||
|
||
|
||
# Create app instance for uvicorn
|
||
app = create_app()
|