deerflow-code/offline-backend-20260512/backend/app/gateway/deps.py
2026-09-07 18:24:55 +08:00

1056 lines
48 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.

"""Centralized accessors for singleton objects stored on ``app.state``.
**Getters** (used by routers): raise 503 when a required dependency is
missing, except ``get_store`` which returns ``None``.
Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, TypeVar, cast
from fastapi import FastAPI, HTTPException, Request
from langgraph.types import Checkpointer
from deerflow.config.app_config import AppConfig
from deerflow.persistence.feedback import FeedbackRepository
from deerflow.runtime import RunContext, RunManager, StreamBridge
from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.notifications import NotificationStore
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.persistence.thread_meta.base import ThreadMetaStore
T = TypeVar("T")
def resolve_roundtable_gateway_mode(raw: str | None) -> str:
"""解析 ``ROUNDTABLE_JOB_GATEWAY``:**默认 ``inprocess``(真模型后台挂起)**。
只有显式写 ``simulated`` 才用模拟网关(前端联调用:真实进度、不调真模型);
缺省 / 空串 / 其它值一律落到 ``inprocess`` —— 部署时无需在 .env 里配置任何值,
后台挂起开箱即用(Phase 3b 已在 Windows 本地与内网 Docker 验证)。
"""
mode = (raw or "").strip().lower()
return "simulated" if mode == "simulated" else "inprocess"
def get_config(request: Request) -> AppConfig:
"""Return the app-scoped ``AppConfig`` stored on ``app.state``."""
config = getattr(request.app.state, "config", None)
if config is None:
raise HTTPException(status_code=503, detail="Configuration not available")
return config
@asynccontextmanager
async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
"""Bootstrap and tear down all LangGraph runtime singletons.
Usage in ``app.py``::
async with langgraph_runtime(app):
yield
"""
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.runtime import make_store, make_stream_bridge
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
from deerflow.runtime.events.store import make_run_event_store
async with AsyncExitStack() as stack:
config = getattr(app.state, "config", None)
if config is None:
raise RuntimeError("langgraph_runtime() requires app.state.config to be initialized")
app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config))
# Initialize persistence engine BEFORE checkpointer so that
# auto-create-database logic runs first (postgres backend).
await init_engine_from_config(config.database)
app.state.checkpointer = await stack.enter_async_context(make_checkpointer(config))
app.state.store = await stack.enter_async_context(make_store(config))
# Initialize repositories — one get_session_factory() call for all.
sf = get_session_factory()
if sf is not None:
from deerflow.persistence.feedback import FeedbackRepository
from deerflow.persistence.run import RunRepository
app.state.run_store = RunRepository(sf)
app.state.feedback_repo = FeedbackRepository(sf)
else:
from deerflow.runtime.runs.store.memory import MemoryRunStore
app.state.run_store = MemoryRunStore()
app.state.feedback_repo = None
from deerflow.persistence.fixed_questions import make_fixed_question_store
app.state.fixed_question_store = make_fixed_question_store(sf)
from deerflow.persistence.thread_meta import make_thread_store
app.state.thread_store = make_thread_store(sf, app.state.store)
from deerflow.persistence.scheduled_tasks import make_scheduled_task_store
app.state.scheduled_task_store = make_scheduled_task_store(sf)
from deerflow.persistence.html_pages import make_html_page_store
app.state.html_page_store = make_html_page_store(sf)
from deerflow.persistence.llm_metrics import make_llm_metrics_store
app.state.llm_metrics_store = make_llm_metrics_store(sf)
from deerflow.persistence.concurrency import make_concurrency_sample_store
app.state.concurrency_sample_store = make_concurrency_sample_store(sf)
from deerflow.persistence.tool_metrics import make_tool_metrics_store
app.state.tool_metrics_store = make_tool_metrics_store(sf)
from deerflow.persistence.agents import make_agent_store
app.state.agent_store = make_agent_store(sf)
from deerflow.persistence.skills import make_skill_store
app.state.skill_store = make_skill_store(sf)
from deerflow.persistence.notifications import (
make_notification_store,
set_notification_store,
)
app.state.notification_store = make_notification_store(sf)
# Make the sender helper (harness layer) able to write without going
# through FastAPI request context — used by cascade.py.
set_notification_store(app.state.notification_store)
from deerflow.persistence.recommended_questions import make_recommended_question_store
app.state.recommended_question_store = make_recommended_question_store(sf)
from deerflow.persistence.tags import make_tag_store
app.state.tag_store = make_tag_store(sf)
from deerflow.persistence.positions import make_position_store
app.state.position_store = make_position_store(sf)
from deerflow.services.position_sync import PositionSyncService
app.state.position_sync = PositionSyncService(sf, app.state.position_store, app.state.tag_store)
from deerflow.persistence.thread_shares import make_thread_share_store
app.state.thread_share_store = make_thread_share_store(sf)
from deerflow.persistence.embed_sessions import make_embed_session_store
app.state.embed_session_store = make_embed_session_store(sf)
from deerflow.persistence.ai_writing_sessions import (
make_ai_writing_session_store,
)
from deerflow.persistence.ai_writing_sessions import (
register_default_store as _register_ai_writing_default_store,
)
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
# 让 LangGraph Server 路径下的 AI 写作节点能跨 app/harness 边界拿到 repo。
# 老 /start /stream /resume 路由仍走 request.app.state,互不影响。
_register_ai_writing_default_store(app.state.ai_writing_session_store)
# AI 写作「后台挂起」执行器:把写作图作为后台任务驱动到定稿(离页/刷新/切号不打断)。
# 用 app.state.checkpointer(与运行时 worker 同一份 saver)续上线程 checkpoint。
from app.gateway.ai_writing_job_executor import AIWritingJobExecutor
app.state.ai_writing_job_executor = AIWritingJobExecutor(app)
# 进程重启续跑:把仍标记为 background 的会话重新拉起驱动(best-effort,不阻塞启动)。
if app.state.ai_writing_session_store is not None:
from app.gateway.ai_writing_job_executor import STATUS_BACKGROUND
async def _reconcile_ai_writing_background() -> None:
try:
sessions = await app.state.ai_writing_session_store.list_by_status(STATUS_BACKGROUND)
app.state.ai_writing_job_executor.reconcile(sessions=sessions)
except Exception: # noqa: BLE001
logger.warning("[ai-writing-bg] startup reconcile failed", exc_info=True)
asyncio.create_task(_reconcile_ai_writing_background())
from deerflow.persistence.article_types import make_article_type_store
app.state.article_type_store = make_article_type_store(sf)
from deerflow.persistence.research_templates import make_research_template_store
app.state.research_template_store = make_research_template_store(sf)
from deerflow.persistence.memory_config import make_user_memory_config_store
app.state.memory_config_store = make_user_memory_config_store(sf)
from deerflow.knowledge import make_knowledge_service
app.state.knowledge_service = make_knowledge_service(sf, config.knowledge)
# Seed the built-in extraction prompts as editable templates so operators
# can immediately view/edit them (idempotent; never overwrites edits).
if app.state.knowledge_service is not None:
try:
await app.state.knowledge_service.seed_default_templates()
except Exception:
logger.exception("Failed to seed knowledge extraction templates")
# Background auto-sedimentation queue (phase 3) — drains finished
# conversations off the chat path. Worker only does work when
# ``knowledge.auto_ingest_enabled`` is set; the queue is always wired so
# the middleware can enqueue without a hard dependency.
from deerflow.knowledge.auto_ingest import AutoIngestQueue, set_auto_ingest_queue
if app.state.knowledge_service is not None:
_auto_ingest_queue = AutoIngestQueue(
lambda: getattr(app.state, "knowledge_service", None),
min_chars=config.knowledge.auto_ingest_min_chars,
)
_auto_ingest_queue.start()
set_auto_ingest_queue(_auto_ingest_queue)
app.state.knowledge_auto_ingest_queue = _auto_ingest_queue
from deerflow.persistence.user_prompts import make_user_prompt_store
app.state.user_prompt_store = make_user_prompt_store(sf)
from deerflow.persistence.user_preferences import make_user_preferences_store
app.state.user_preferences_store = make_user_preferences_store(sf)
from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不按 user 分权)。
from deerflow.persistence.roundtable_task_drafts import make_roundtable_task_draft_store
app.state.roundtable_task_draft_store = make_roundtable_task_draft_store(sf)
from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.business_mapping import make_business_mapping_store
app.state.business_mapping_store = make_business_mapping_store(sf)
from deerflow.persistence.light_apps import make_light_app_store
app.state.light_app_store = make_light_app_store(sf)
from deerflow.persistence.workflows import make_workflow_store
app.state.workflow_store = make_workflow_store(sf)
from deerflow.persistence.workflow_data_sources import make_workflow_data_source_store
from deerflow.persistence.workflow_events import make_workflow_event_store
from deerflow.persistence.workflow_planning import make_workflow_planning_store
from deerflow.persistence.workflow_runs import make_workflow_run_store
app.state.workflow_run_store = make_workflow_run_store(sf)
app.state.workflow_event_store = make_workflow_event_store(sf, run_store=app.state.workflow_run_store)
app.state.workflow_data_source_store = make_workflow_data_source_store(sf)
# Candidate plans are durable user-editable drafts, deliberately kept
# separate from both the canvas draft and a formal workflow run.
app.state.workflow_planning_store = make_workflow_planning_store(sf)
from app.report_collaboration.execution.live_hub import PublishingReportCollaborationStore, ReportCollaborationLiveHub
from deerflow.persistence.report_collaboration import make_report_collaboration_store
app.state.report_collaboration_live_hub = ReportCollaborationLiveHub()
app.state.report_collaboration_store = PublishingReportCollaborationStore(
make_report_collaboration_store(sf),
app.state.report_collaboration_live_hub,
)
app.state.report_collaboration_executor = None
app.state.report_collaboration_dispatcher = None
rc_cfg = getattr(config, "report_collaboration", None)
if rc_cfg is not None and rc_cfg.enabled and rc_cfg.worker_enabled:
from app.report_collaboration.agentscope_runtime.member_kernel import AgentScopeMemberKernel
from app.report_collaboration.execution.dispatcher import ReportCollaborationDispatcher
from app.report_collaboration.execution.executor import ReportCollaborationExecutor
kernel = AgentScopeMemberKernel(app.state.report_collaboration_store, app_config=config, config=rc_cfg)
executor = ReportCollaborationExecutor(app.state.report_collaboration_store, kernel, config=rc_cfg)
dispatcher = ReportCollaborationDispatcher(app.state.report_collaboration_store, executor, config=rc_cfg)
app.state.report_collaboration_executor = executor
app.state.report_collaboration_dispatcher = dispatcher
dispatcher.start()
from deerflow.persistence.menu_overrides import make_menu_override_store
app.state.menu_override_store = make_menu_override_store(sf)
from deerflow.persistence.compact_menu_overrides import make_compact_menu_override_store
app.state.compact_menu_override_store = make_compact_menu_override_store(sf)
from deerflow.persistence.sensitive_words import make_sensitive_word_store
app.state.sensitive_word_store = make_sensitive_word_store(sf)
from deerflow.persistence.dashboard_sessions import make_dashboard_session_store
app.state.dashboard_session_store = make_dashboard_session_store(sf)
from deerflow.persistence.task_buttons import make_task_button_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.report_structures import make_report_structure_store
app.state.report_structure_store = make_report_structure_store(sf)
from deerflow.integrations.weknora.runtime import register_llmwiki_store
from deerflow.persistence.llmwiki import make_llmwiki_store
app.state.llmwiki_store = make_llmwiki_store(sf)
register_llmwiki_store(app.state.llmwiki_store)
# Skill distillation and the single global assistant knowledge base.
# Both repositories live in ``deerflow.*``; only this composition layer
# wires them to Gateway executors and remote adapters.
from deerflow.persistence.assistant_knowledge import make_assistant_knowledge_store
from deerflow.persistence.skill_knowledge import make_skill_knowledge_store
app.state.assistant_knowledge_store = make_assistant_knowledge_store(sf)
app.state.skill_knowledge_store = make_skill_knowledge_store(sf)
if app.state.skill_knowledge_store is not None and app.state.assistant_knowledge_store is not None:
from app.gateway.skill_knowledge_job_dispatcher import SkillKnowledgeJobDispatcher
from app.gateway.skill_knowledge_job_executor import SkillKnowledgeJobExecutor
app.state.skill_knowledge_executor = SkillKnowledgeJobExecutor(app)
app.state.skill_knowledge_dispatcher = SkillKnowledgeJobDispatcher(
app.state.skill_knowledge_store,
app.state.skill_knowledge_executor,
)
app.state.skill_knowledge_dispatcher.start()
from app.gateway.skill_knowledge_auto_sync import SkillKnowledgeAutoSyncService
app.state.skill_knowledge_auto_sync = SkillKnowledgeAutoSyncService(app)
app.state.skill_knowledge_auto_sync.start()
else:
app.state.skill_knowledge_executor = None
app.state.skill_knowledge_dispatcher = None
app.state.skill_knowledge_auto_sync = None
# DeerFlow-local Wiki mirror/vector index. The store is always wired so
# migrations and status inspection work while feature flags are off;
# network/search services only exist when explicitly enabled.
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
local_index_config = config.llmwiki.local_wiki_index
app.state.llmwiki_index_store = make_llmwiki_index_store(
sf,
write_batch_size=local_index_config.database_batch_size,
)
app.state.llmwiki_embedding = None
app.state.llmwiki_vector_cache = None
app.state.llmwiki_vector_search = None
app.state.llmwiki_sync_service = None
app.state.llmwiki_retrieval_service = None
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
embedding_config = local_index_config.embedding
# The administrator may select or upload the offline encoder from the
# UI. This runtime-owned value deliberately overrides config.yaml and
# survives application restarts without modifying release assets.
if embedding_config.provider == "local":
from deerflow.config.system_settings import load_system_settings
runtime_model_path = load_system_settings().local_wiki_encoder.model_path.strip()
if runtime_model_path:
embedding_config = embedding_config.model_copy(
update={"local_model_path": runtime_model_path}
)
embedding_configured = bool(
embedding_config.enabled
and (embedding_config.base_url or embedding_config.provider == "local")
and embedding_config.model.strip()
)
if embedding_configured:
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(embedding_config)
if local_index_config.enabled and app.state.llmwiki_embedding is not None:
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
app.state.llmwiki_vector_cache = WikiVectorCache(
app.state.llmwiki_index_store,
max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
revision_check_seconds=local_index_config.cache_revision_check_seconds,
)
app.state.llmwiki_vector_search = WikiVectorSearchService(
local_index_config,
app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache,
)
if weknora_client is not None:
app.state.llmwiki_sync_service = WikiSyncService(
local_index_config,
app.state.llmwiki_index_store,
app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache,
weknora_client,
)
from app.gateway.knowledge_transfer import sync_to_global
async def on_wiki_vectorized(mapping):
return await sync_to_global(app, mapping)
app.state.llmwiki_sync_service.on_completed = on_wiki_vectorized
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
app.state.llmwiki_retrieval_service = WikiRetrievalService(
app.state.llmwiki_index_store,
vector_search=(
app.state.llmwiki_vector_search
if local_index_config.internal_search_enabled
else None
),
client=weknora_client,
)
register_local_wiki_index_runtime(
store=app.state.llmwiki_index_store,
search=app.state.llmwiki_vector_search,
sync=app.state.llmwiki_sync_service,
retrieval=app.state.llmwiki_retrieval_service,
)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
# Task-scoped artifact submission status: shared by task; actor is audit-only.
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
)
from deerflow.persistence.roundtable_jobs import (
register_default_store as _register_roundtable_job_default_store,
)
app.state.roundtable_job_store = make_roundtable_job_store(sf)
# 让 LangGraph Server 路径下的圆桌编排图节点能跨 app/harness 边界拿到 repo
# (Phase 2 后台编排图写进度用)。
_register_roundtable_job_default_store(app.state.roundtable_job_store)
# 意图回合单飞 store(Phase 4):``/api/intent/stream`` 据此对 (thread_id,
# client_turn_id) 跨 worker 单飞 + 幂等回放,杜绝并发双发意图。sf 为 None(无 DB)
# 时为 None,路由侧判空跳过单飞、走原流程。
from deerflow.persistence.intent_turns import make_intent_turn_store
app.state.intent_turn_store = make_intent_turn_store(sf)
# 圆桌会商「诊断日志」store:会商全流程各处报错 / 关键事件落库,供管理员诊断页筛选。
# 同样登记进程级默认仓储——harness 侧编排引擎 / 进程内网关跨边界写诊断用。
from deerflow.persistence.roundtable_diagnostics import (
make_roundtable_diagnostic_store,
)
from deerflow.persistence.roundtable_diagnostics import (
register_default_store as _register_roundtable_diagnostic_default_store,
)
app.state.roundtable_diagnostic_store = make_roundtable_diagnostic_store(sf)
_register_roundtable_diagnostic_default_store(app.state.roundtable_diagnostic_store)
# 圆桌后台作业执行器:把编排引擎作为后台任务跑。
# 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择(见 resolve_roundtable_gateway_mode):
# - 缺省 / "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report)——**默认**,
# 部署时无需任何配置即可用后台挂起;
# - 显式 "simulated"(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
if app.state.roundtable_job_store is not None:
import os
from app.gateway.roundtable_job_executor import RoundtableJobExecutor
gateway_mode = resolve_roundtable_gateway_mode(os.getenv("ROUNDTABLE_JOB_GATEWAY"))
if gateway_mode == "simulated":
from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway
# delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。
gateway_factory = lambda p: SimulatedRoundtableGateway( # noqa: E731
p.agents, mode=p.mode, delay=3.0
)
logger.info("[roundtable-jobs] gateway = simulated")
else:
from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
_app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
_app,
user_id=p.user_id,
agents=p.agents,
model=p.model,
# 席位执行模式沿用 Step2 选择,后台席位 run 据此覆盖 policy 的 thinking/reasoning + 开子代理。
seat_thinking_enabled=p.seat_thinking_enabled,
seat_reasoning_effort=p.seat_reasoning_effort,
seat_subagent_enabled=p.seat_subagent_enabled,
seat_skill_directives=p.seat_skill_directives,
# 每席位推理深度覆盖(业务链条里各席位单独配):run_seat 命中即覆盖作业级 seatMode。
seat_modes=p.seat_modes,
# 诊断日志上下文:网关每轮 leader/seat/report/summary/dashboard 报错均带这些归属。
job_id=p.job_id,
draft_id=p.draft_id,
task_id=p.task_id,
)
logger.info("[roundtable-jobs] gateway = inprocess (real models, default)")
app.state.roundtable_job_executor = RoundtableJobExecutor(
app.state.roundtable_job_store,
gateway_factory=gateway_factory,
draft_store=getattr(app.state, "roundtable_draft_store", None),
# task 作业把最终报告写回**独立的** task-draft 存储(不分权)。
task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
)
# Phase 3 dispatcher:跨 worker 领取 queued/租约过期作业 + outbox 补偿器。
# 每个 worker 进程都跑一个;谁先 claim_job 成功谁执行(DB 条件 UPDATE 互斥),
# 崩溃的 worker 租约过期后由其它 worker 接管。路由 start/resume 后 nudge 立即唤醒。
from app.gateway.roundtable_job_dispatcher import RoundtableJobDispatcher
app.state.roundtable_job_dispatcher = RoundtableJobDispatcher(
app.state.roundtable_job_store,
app.state.roundtable_job_executor,
draft_store=getattr(app.state, "roundtable_draft_store", None),
task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
)
app.state.roundtable_job_dispatcher.start()
else:
app.state.roundtable_job_executor = None
app.state.roundtable_job_dispatcher = None
# ── Deep Research (深度研究) stores + executor + dispatcher ──────────
# Five tables: sessions / jobs / events / sources / messages.
# When DB is available, the executor + dispatcher run background research
# jobs (lease-based claim, crash-recoverable) mirroring the roundtable pattern.
from deerflow.persistence.deep_research_events import (
make_deep_research_event_store,
)
from deerflow.persistence.deep_research_jobs import make_deep_research_job_store
from deerflow.persistence.deep_research_messages import (
make_deep_research_message_store,
)
from deerflow.persistence.deep_research_sessions import (
make_deep_research_session_store,
)
from deerflow.persistence.deep_research_sources import (
make_deep_research_source_store,
)
from deerflow.persistence.document_rewrite_versions import (
make_document_rewrite_version_store,
)
app.state.deep_research_session_store = make_deep_research_session_store(sf)
app.state.deep_research_job_store = make_deep_research_job_store(sf)
app.state.deep_research_event_store = make_deep_research_event_store(sf)
app.state.deep_research_source_store = make_deep_research_source_store(sf)
app.state.deep_research_message_store = make_deep_research_message_store(sf)
# Whole-document AI rewrites retain a durable original snapshot so a
# completed replacement can be safely undone after a refresh.
app.state.document_rewrite_version_store = make_document_rewrite_version_store(sf)
# Low-latency in-process SSE fan-out. The database event log remains
# authoritative for reconnect replay and cross-worker recovery.
from app.gateway.deep_research_live_hub import DeepResearchLiveHub
app.state.deep_research_live_hub = DeepResearchLiveHub()
if app.state.deep_research_job_store is not None:
from app.gateway.deep_research_job_dispatcher import (
DeepResearchJobDispatcher,
)
from app.gateway.deep_research_job_executor import (
DeepResearchJobExecutor,
)
app.state.deep_research_executor = DeepResearchJobExecutor(
app.state.deep_research_job_store,
app.state.deep_research_session_store,
app.state.deep_research_event_store,
app.state.deep_research_source_store,
live_publisher=app.state.deep_research_live_hub.publish,
live_subscriber_probe=app.state.deep_research_live_hub.has_subscribers,
checkpointer=app.state.checkpointer,
document_rewrite_version_store=app.state.document_rewrite_version_store,
app_config=config,
)
app.state.deep_research_dispatcher = DeepResearchJobDispatcher(
app.state.deep_research_job_store,
app.state.deep_research_executor,
)
app.state.deep_research_dispatcher.start()
else:
app.state.deep_research_executor = None
app.state.deep_research_dispatcher = None
# ── Workflow Studio runtime: live hub + executor + lease dispatcher ──
# Every worker runs a dispatcher; the atomic claim decides ownership and
# an expired lease is how a crashed worker's run gets picked back up.
workflow_cfg = getattr(config, "workflows", None)
if workflow_cfg is not None and workflow_cfg.enabled:
from app.gateway.workflow_dispatcher import WorkflowRunDispatcher
from app.gateway.workflow_executor import WorkflowRunExecutor
from app.gateway.workflow_live_hub import WorkflowLiveHub
app.state.workflow_live_hub = WorkflowLiveHub()
app.state.workflow_executor = WorkflowRunExecutor(
app,
run_store=app.state.workflow_run_store,
event_store=app.state.workflow_event_store,
workflow_store=app.state.workflow_store,
data_source_store=app.state.workflow_data_source_store,
live_publisher=app.state.workflow_live_hub.publish,
config=workflow_cfg,
)
app.state.workflow_dispatcher = WorkflowRunDispatcher(
app.state.workflow_run_store,
app.state.workflow_executor,
event_store=app.state.workflow_event_store,
live_publisher=app.state.workflow_live_hub.publish,
config=workflow_cfg,
)
app.state.workflow_dispatcher.start()
from app.gateway.workflow_retention import WorkflowRetentionCleaner
app.state.workflow_retention = WorkflowRetentionCleaner(
app.state.workflow_run_store,
app.state.workflow_event_store,
config=workflow_cfg,
)
app.state.workflow_retention.start()
else:
app.state.workflow_live_hub = None
app.state.workflow_executor = None
app.state.workflow_dispatcher = None
app.state.workflow_retention = None
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
try:
yield
finally:
from app.gateway.assistant_file_ingest import stop_file_jobs
await stop_file_jobs()
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
if llmwiki_embedding is not None:
await llmwiki_embedding.close()
from deerflow.persistence.notifications import set_notification_store
set_notification_store(None)
dispatcher = getattr(app.state, "roundtable_job_dispatcher", None)
if dispatcher is not None:
await dispatcher.stop()
dr_dispatcher = getattr(app.state, "deep_research_dispatcher", None)
if dr_dispatcher is not None:
await dr_dispatcher.stop()
wf_dispatcher = getattr(app.state, "workflow_dispatcher", None)
if wf_dispatcher is not None:
await wf_dispatcher.stop()
rc_dispatcher = getattr(app.state, "report_collaboration_dispatcher", None)
if rc_dispatcher is not None:
await rc_dispatcher.stop()
wf_retention = getattr(app.state, "workflow_retention", None)
if wf_retention is not None:
await wf_retention.stop()
from deerflow.workflows.runtime.sql_runner import dispose_engines
await dispose_engines()
skill_knowledge_auto_sync = getattr(app.state, "skill_knowledge_auto_sync", None)
if skill_knowledge_auto_sync is not None:
await skill_knowledge_auto_sync.close()
skill_knowledge_dispatcher = getattr(app.state, "skill_knowledge_dispatcher", None)
if skill_knowledge_dispatcher is not None:
await skill_knowledge_dispatcher.close()
queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
if queue is not None:
from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
await queue.stop()
set_auto_ingest_queue(None)
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
# ---------------------------------------------------------------------------
# Getters – called by routers per-request
# ---------------------------------------------------------------------------
def _require(attr: str, label: str) -> Callable[[Request], T]:
"""Create a FastAPI dependency that returns ``app.state.<attr>`` or 503."""
def dep(request: Request) -> T:
val = getattr(request.app.state, attr, None)
if val is None:
raise HTTPException(status_code=503, detail=f"{label} not available")
return cast(T, val)
dep.__name__ = dep.__qualname__ = f"get_{attr}"
return dep
get_stream_bridge: Callable[[Request], StreamBridge] = _require("stream_bridge", "Stream bridge")
get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run manager")
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
def get_store(request: Request):
"""Return the global store (may be ``None`` if not configured)."""
return getattr(request.app.state, "store", None)
def get_thread_store(request: Request) -> ThreadMetaStore:
"""Return the thread metadata store (SQL or memory-backed)."""
val = getattr(request.app.state, "thread_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Thread metadata store not available")
return val
def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
"""Return the scheduled-task store."""
val = getattr(request.app.state, "scheduled_task_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task store not available")
return val
def get_llm_metrics_store(request: Request):
"""Return the LLM metrics store, or None when not configured."""
return getattr(request.app.state, "llm_metrics_store", None)
def get_concurrency_sample_store(request: Request):
"""Return the concurrency-sample store, or None on the memory backend."""
return getattr(request.app.state, "concurrency_sample_store", None)
def get_tool_metrics_store(request: Request):
"""Return the tool-call metrics store, or None when not configured."""
return getattr(request.app.state, "tool_metrics_store", None)
def get_agent_store(request: Request) -> AgentStore:
"""Return the custom-agent store."""
val = getattr(request.app.state, "agent_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Agent store not available")
return val
def get_skill_store(request: Request) -> SkillStore:
"""Return the custom-skill store."""
val = getattr(request.app.state, "skill_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Skill store not available")
return val
def get_notification_store(request: Request) -> NotificationStore:
"""Return the in-app notification store."""
val = getattr(request.app.state, "notification_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Notification store not available")
return val
def get_tag_store(request: Request) -> TagStore:
"""Return the tag store."""
val = getattr(request.app.state, "tag_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Tag store not available")
return val
def get_position_store(request: Request):
"""Return the 岗位 (position) store."""
val = getattr(request.app.state, "position_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Position store not available")
return val
def get_position_sync(request: Request):
"""Return the position → subscription sync service."""
val = getattr(request.app.state, "position_sync", None)
if val is None:
raise HTTPException(status_code=503, detail="Position sync service not available")
return val
def get_thread_share_store(request: Request):
"""Return the conversation share-link store."""
val = getattr(request.app.state, "thread_share_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Thread share store not available")
return val
def get_roundtable_draft_share_store(request: Request):
"""Return the roundtable-draft share-link store."""
val = getattr(request.app.state, "roundtable_draft_share_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Roundtable draft share store not available")
return val
def get_embed_session_store(request: Request):
"""Return the iframe embed session → thread mapping store."""
val = getattr(request.app.state, "embed_session_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Embed session store not available")
return val
def get_ai_writing_session_store(request: Request):
"""Return the AI writing session store, or None when not configured."""
return getattr(request.app.state, "ai_writing_session_store", None)
def get_ai_writing_job_executor(request: Request):
"""Return the AI writing background-suspend executor, or None when unavailable."""
return getattr(request.app.state, "ai_writing_job_executor", None)
def get_article_type_store(request: Request):
"""Return the article type store, or None when not configured."""
return getattr(request.app.state, "article_type_store", None)
def get_research_template_store(request: Request):
"""Return the research template store, or None when not configured."""
return getattr(request.app.state, "research_template_store", None)
def get_memory_config_store(request: Request):
"""Return the per-user memory config store."""
val = getattr(request.app.state, "memory_config_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Memory config store not available")
return val
def get_knowledge_service(request: Request):
"""Return the knowledge base service (503 when disabled / memory backend)."""
val = getattr(request.app.state, "knowledge_service", None)
if val is None:
raise HTTPException(status_code=503, detail="Knowledge base not available")
return val
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
Returns a *base* context with infrastructure dependencies.
"""
config = get_config(request)
return RunContext(
checkpointer=get_checkpointer(request),
store=get_store(request),
event_store=get_run_event_store(request),
run_events_config=getattr(config, "run_events", None),
thread_store=get_thread_store(request),
app_config=config,
)
# ---------------------------------------------------------------------------
# Auth helpers (used by authz.py and auth middleware)
# ---------------------------------------------------------------------------
# Cached singletons to avoid repeated instantiation per request
_cached_local_provider: LocalAuthProvider | None = None
_cached_repo: SQLiteUserRepository | None = None
def get_request_access_token(request: Request) -> str | None:
"""Extract the caller identity, preferring the explicit Bearer header.
An iframe or another tab can rewrite the origin-wide cookie, while the
frontend deliberately sources its Bearer token from this tab's auth store.
"""
from app.gateway.auth.token_source import get_explicit_or_session_token
request_token = get_explicit_or_session_token(request)
if request_token:
return request_token
from app.gateway.proxy_path import app_relative_path
path = app_relative_path(request).rstrip("/")
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
query_token = (request.query_params.get("access_token") or "").strip()
if query_token:
return query_token
return None
def get_local_provider() -> LocalAuthProvider:
"""Get or create the cached LocalAuthProvider singleton.
Must be called after ``init_engine_from_config()`` — the shared
session factory is required to construct the user repository.
"""
global _cached_local_provider, _cached_repo
if _cached_repo is None:
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.persistence.engine import get_session_factory
sf = get_session_factory()
if sf is None:
raise RuntimeError("get_local_provider() called before init_engine_from_config(); cannot access users table")
_cached_repo = SQLiteUserRepository(sf)
if _cached_local_provider is None:
from app.gateway.auth.local_provider import LocalAuthProvider
_cached_local_provider = LocalAuthProvider(repository=_cached_repo)
return _cached_local_provider
async def get_current_user_from_request(request: Request):
"""Get the current authenticated user from request token.
Raises HTTPException 401 if not authenticated.
"""
from app.gateway.auth import decode_token
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
access_token = get_request_access_token(request)
if not access_token:
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(),
)
payload = decode_token(access_token)
if isinstance(payload, TokenError):
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(),
)
provider = get_local_provider()
user = await provider.get_user(payload.sub)
if user is None:
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(),
)
# Token version mismatch → password was changed, token is stale
if user.token_version != payload.ver:
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(),
)
return user
async def get_optional_user_from_request(request: Request):
"""Get optional authenticated user from request.
Returns None if not authenticated.
"""
try:
return await get_current_user_from_request(request)
except HTTPException:
return None
async def get_current_user(request: Request) -> str | None:
"""Extract user_id from request cookie, or None if not authenticated.
Thin adapter that returns the string id for callers that only need
identification (e.g., ``feedback.py``). Full-user callers should use
``get_current_user_from_request`` or ``get_optional_user_from_request``.
"""
user = await get_optional_user_from_request(request)
return str(user.id) if user else None