Files
adminandClaude Fable 5 2ee9af4bd0 fix(bioflow): 路径登记目录展开+黑名单+自动挂载
- 登记目录自动展开多文件(扩展名白名单过滤,上限 200)
- 移除白名单根校验(用户数据可在任意位置),改敏感路径黑名单
  (/etc /root /proc /var/lib/docker 等前缀 + .env/*.key/id_ed25519 等文件名)
- 登记文件 symlink 进任务 input + server_registered.json 持久化
- 执行容器自动挂载登记路径(-v src:src:ro),跨重启恢复
- 前端 chat.js 兼容 items[] 响应(单文件亦在 items[0])
- 336 用例全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 23:11:27 +08:00

248 lines
8.8 KiB
Python
Raw Permalink 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.
"""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 供测试替换)。
BUG-R1-03 修复:生产启动链接线 FlowStore——此前 build_context 恒不传
flow_store(默认 None),部署态 ask/chat_qa 引用块恒空(citations=[])。
现在按配置 flow_docs_dir 加载本地实例(vstore/data/flow_docs 已入库 724
块);缺失/失败自动降级 None(planner 走 llm_only,服务不阻断)。
"""
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()
flow_store = init_flow_store(get_settings().flow_docs_dir_abs)
context = build_context(db=database, flow_store=flow_store)
# TL 接线(2026-09-12):扩展执行器接 Dev-B extension_exec。
# 失败(如 docker 不可用/配置缺失)静默降级——trial/run 返回 503 而非 500。
try:
from .services.bioflow import extensions as ext_mod
# 2026-09-15 修复:裸 ExtensionExecConfig() 的 entry_script_dir 为空串,
# build_command 用相对路径 run_extension.py 在 176 cwd 下找不到入口。
# 改走 _default_exec_config()(经 _resolve_extension_entry_dir 解析部署树路径)。
from .services.bioflow.extension_exec import _default_exec_config, ExtensionExecutor as RealExecutor
cfg = _default_exec_config()
real = RealExecutor(cfg)
store = ext_mod.get_store()
if store is not None:
ext_mod.inject_real_executor(real, store)
logger.info("extension executor wired (image suffix=%s)", cfg.docker_tag_suffix)
except Exception as exc: # noqa: BLE001
logger.warning("extension executor wiring skipped: %s", exc)
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"),
)
# 登记路径恢复(server_registered.json;供执行容器自动挂载)
try:
import json as _json
reg_file = record.job_dir / "server_registered.json"
if reg_file.is_file():
record.registered_server_paths = (
_json.loads(reg_file.read_text(encoding="utf-8"))
.get("registered_server_paths") or []
)
except Exception: # noqa: BLE001 - 恢复失败不阻断(挂载缺失→阶段报错可查)
pass
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