"""app/context.py —— 生信服务共享实例容器(db/events/kernel/catalog/runner/semaphore/planner)。 - 生产:app 启动时 init_context() 加载真实依赖(SQLite、stage_image_map.json、 vstore FlowStore 本地实例(存在时)、真 docker 执行器)。 - 测试:build_context() 可注入 fake db/executor/flow_store(mock docker/LLM/vstore)。 - vstore 不存在/不可用:FlowStore 为 None,planner 自动降级 llm_only(FR-BIO-010 降级)。 - 并发信号量(BIOFLOW_CONCURRENCY,FR-BIO-031)每 context 一个。 """ from __future__ import annotations import asyncio import logging from dataclasses import dataclass, field from typing import Any from .config import get_settings from .db import Database, db as default_db from .services.bioflow.catalog import StageCatalog, build_catalog from .services.bioflow.events import EventBus from .services.bioflow.kernel import Kernel, KernelConfig, load_kernel from .services.bioflow.planner import Planner from .services.bioflow.runner import Runner logger = logging.getLogger(__name__) @dataclass class Context: db: Any = None events: EventBus | None = None kernel: Kernel | None = None catalog: StageCatalog | None = None runner: Runner | None = None semaphore: asyncio.Semaphore | None = None planner: Planner | None = None flow_store: Any = None executor: Any = None records: dict[str, Any] = field(default_factory=dict) # job_id → JobRecord(内存) _tasks: list[asyncio.Task] = field(default_factory=list) def spawn(self, coro: Any) -> asyncio.Task: task = asyncio.create_task(coro) self._tasks.append(task) task.add_done_callback(lambda t: self._tasks.remove(t) if t in self._tasks else None) return task async def close(self) -> None: for task in self._tasks: task.cancel() if self._tasks: await asyncio.gather(*self._tasks, return_exceptions=True) self._tasks.clear() # 全局 context(测试可整体替换) context: Context = Context() def init_events(jobs_dir: str) -> EventBus: from pathlib import Path return EventBus(Path(jobs_dir) / "events") def init_flow_store(flow_docs_dir: str) -> Any | None: """vstore FlowStore 本地实例(存在则加载;失败/缺失返回 None → planner 降级)。""" try: import sys from pathlib import Path repo_root = Path(__file__).resolve().parent.parent.parent if str(repo_root) not in sys.path: sys.path.insert(0, str(repo_root)) from vstore.flow import FlowStore # P6 A4 交付;缺失则降级 return FlowStore(flow_docs_dir) except Exception as exc: # noqa: BLE001 - 缺失/损坏降级 llm_only,不阻断服务 logger.warning("context: vstore FlowStore 不可用(planner 降级 llm_only): %s", exc) return None def build_context( *, db: Database | None = None, events: EventBus | None = None, kernel: Kernel | None = None, catalog: StageCatalog | None = None, executor: Any = None, semaphore: asyncio.Semaphore | None = None, flow_store: Any = None, runner_kwargs: dict[str, Any] | None = None, ) -> Context: """构造 context(生产与测试共用;各依赖可注入)。 executor=None 时用 kernel.run_stage(真 docker)。 """ settings = get_settings() database = db or default_db bus = events or init_events(settings.jobs_dir_abs) kern = kernel or load_kernel() cat = catalog or build_catalog(kern.map) sem = semaphore or asyncio.Semaphore(settings.concurrency) exec_fn = executor or kern.run_stage runner = Runner( database, bus, exec_fn, cat, sem, **(runner_kwargs or {}), ) planner = Planner( cat, flow_store, llm_plan_timeout=settings.llm_plan_timeout, plan_candidates=settings.plan_candidates, plan_max_rounds=settings.plan_max_rounds, archive_finder=_make_archive_finder(database), ) return Context( db=database, events=bus, kernel=kern, catalog=cat, runner=runner, semaphore=sem, planner=planner, flow_store=flow_store, executor=exec_fn, ) def _make_archive_finder(database: Database): """planner 复用归档查找回调(async;store 查询)。""" from .services.bioflow import store async def find(name: str) -> dict[str, Any] | None: try: return await store.find_archive_by_name(database, name) except Exception: # noqa: BLE001 return None return find async def init_context(db_path: str | None = None) -> Context: """应用启动:初始化全局 context(幂等;db_path 供测试替换)。""" global context from .db import init_db database = context.db if db_path is not None: database = await init_db(db_path) elif context.db is None: database = await init_db() context = build_context(db=database) return context async def shutdown_context() -> None: await context.close() # ---- JobRecord 注册与恢复(路由层操作任务的内存记录)---- def register_record(record: Any) -> None: context.records[record.id] = record def _record_from_job(job: dict[str, Any], stages: list[dict[str, Any]]) -> Any: """DB 行 → JobRecord(无运行中内存记录时恢复,跨重启安全)。""" from .services.bioflow.runner import JobRecord, params_dict record = JobRecord( id=job["id"], session_id=job["session_id"], title=job["title"], status=job["status"], stage_chain=list(job["stage_chain"] or []), params=params_dict(job.get("params")), auto_continue=bool(job.get("auto_continue")), archive_id=job.get("archive_id"), ) for s in stages: record._stage_states[s["stage"]] = { "status": s["status"], "params": params_dict(s.get("params")), "attempt": int(s.get("attempt") or 0), "manifest_path": s.get("manifest_path"), "log_tail": s.get("log_tail") or "", "error": s.get("error"), "started_at": s.get("started_at"), "finished_at": s.get("finished_at"), } return record async def load_record(job_id: str) -> Any | None: """取任务 JobRecord:内存优先,否则 DB 恢复(并注册回内存)。""" if job_id in context.records: return context.records[job_id] if context.db is None: return None from .services.bioflow import store job = await store.get_job(context.db, job_id) if job is None: return None stages = await store.list_stages(context.db, job_id) record = _record_from_job(job, stages) context.records[job_id] = record return record