docs: 为全部代码补充中文注释并加入 AGENTS 注释规范
This commit is contained in:
+5
-1
@@ -1 +1,5 @@
|
||||
"""WOV platform API package."""
|
||||
"""WOV 平台 API 包。
|
||||
|
||||
包含 FastAPI 应用入口、SQLite 数据访问、节点生命周期管理、工作流调度器以及
|
||||
管理端/用户端路由。
|
||||
"""
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
"""应用配置中心。
|
||||
|
||||
集中读取环境变量并推导路径常量,避免业务代码散落魔法值。路径统一使用
|
||||
pathlib,Windows 与 Linux 开发环境均可用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# WOV 工作区根目录:wov-api 位于其下的子目录,父目录即 meta-repo。
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
# wov-sdk 使用 src 布局,启动时加入 PYTHONPATH 以便直接导入。
|
||||
SDK_SRC = WORKSPACE_ROOT / "wov-sdk" / "src"
|
||||
|
||||
if str(SDK_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SDK_SRC))
|
||||
|
||||
# 数据目录、SQLite 文件与产物存储目录均可通过环境变量覆盖,便于测试隔离。
|
||||
DATA_DIR = Path(os.getenv("WOV_DATA_DIR", str(WORKSPACE_ROOT / "wov-api" / "data")))
|
||||
DB_PATH = Path(os.getenv("WOV_DB_PATH", str(DATA_DIR / "wov.db")))
|
||||
STORAGE_DIR = Path(os.getenv("WOV_STORAGE_DIR", str(DATA_DIR / "storage")))
|
||||
|
||||
# 节点回收线程的轮询间隔。
|
||||
NODE_REAP_INTERVAL_SECONDS = float(os.getenv("WOV_REAP_INTERVAL_SECONDS", "3"))
|
||||
# 节点进程启动后等待 WOV_NODE_READY 与健康检查的超时。
|
||||
NODE_READY_TIMEOUT_SECONDS = float(os.getenv("WOV_READY_TIMEOUT_SECONDS", "12"))
|
||||
# 单次节点调用(POST /invoke)的 HTTP 超时,LLM 等慢节点需要放宽。
|
||||
NODE_INVOKE_TIMEOUT_SECONDS = float(os.getenv("WOV_NODE_INVOKE_TIMEOUT_SECONDS", "3600"))
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""SQLite 数据访问层。
|
||||
|
||||
所有持久化逻辑集中在本模块,业务代码只依赖 Database 提供的方法。后续切换
|
||||
PostgreSQL 时只需替换本层实现,不修改调度器与路由的业务逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -10,15 +16,21 @@ from wov_sdk.models import NodeManifest
|
||||
|
||||
|
||||
class Database:
|
||||
"""SQLite 数据库封装:负责建表以及节点/实例/工作流/任务/产物的 CRUD。"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""打开数据库并确保父目录存在、表结构已初始化。"""
|
||||
self.path = path
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
@contextmanager
|
||||
def _connect(self) -> Iterator[sqlite3.Connection]:
|
||||
"""提供带事务提交的数据库连接上下文。"""
|
||||
conn = sqlite3.connect(self.path)
|
||||
# 按列名读取结果,返回 dict 更直观。
|
||||
conn.row_factory = sqlite3.Row
|
||||
# 开启外键约束,保证子表记录引用有效。
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
yield conn
|
||||
@@ -27,9 +39,11 @@ class Database:
|
||||
conn.close()
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
"""创建全部业务表;已存在的表保持不变。"""
|
||||
with self._connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
-- 节点注册表:保存节点的最新 manifest JSON。
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -39,6 +53,7 @@ class Database:
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 节点实例表:记录 NodeManager 启动的进程及其状态。
|
||||
CREATE TABLE IF NOT EXISTS node_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
@@ -52,6 +67,7 @@ class Database:
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
|
||||
-- 工作流表:只保存概要信息,完整定义存版本表。
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -61,6 +77,7 @@ class Database:
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 工作流版本表:每个版本保存一份 DAG 定义 JSON。
|
||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workflow_id TEXT NOT NULL,
|
||||
@@ -71,6 +88,7 @@ class Database:
|
||||
FOREIGN KEY(workflow_id) REFERENCES workflows(id)
|
||||
);
|
||||
|
||||
-- 工作流运行表:记录任务从排队到完成/失败的状态机。
|
||||
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL,
|
||||
@@ -85,6 +103,7 @@ class Database:
|
||||
FOREIGN KEY(workflow_id) REFERENCES workflows(id)
|
||||
);
|
||||
|
||||
-- 产物表:记录每个任务各节点的输出 URI,按名称唯一。
|
||||
CREATE TABLE IF NOT EXISTS artifacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
@@ -101,6 +120,7 @@ class Database:
|
||||
)
|
||||
|
||||
def upsert_node(self, manifest: NodeManifest) -> None:
|
||||
"""插入或更新节点注册记录,同名 ID 覆盖为新版本。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -122,6 +142,7 @@ class Database:
|
||||
)
|
||||
|
||||
def get_node(self, node_id: str) -> NodeManifest | None:
|
||||
"""按 ID 读取节点并反序列化为 NodeManifest。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT manifest_json FROM nodes WHERE id = ?", (node_id,)).fetchone()
|
||||
if row is None:
|
||||
@@ -129,16 +150,20 @@ class Database:
|
||||
return NodeManifest.from_dict(json.loads(row["manifest_json"]))
|
||||
|
||||
def list_nodes(self) -> list[NodeManifest]:
|
||||
"""按 ID 顺序返回全部注册节点。"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute("SELECT manifest_json FROM nodes ORDER BY id").fetchall()
|
||||
return [NodeManifest.from_dict(json.loads(row["manifest_json"])) for row in rows]
|
||||
|
||||
def delete_node(self, node_id: str) -> None:
|
||||
"""删除节点及其全部实例记录。"""
|
||||
with self._connect() as conn:
|
||||
# 先删实例再删节点,满足外键约束。
|
||||
conn.execute("DELETE FROM node_instances WHERE node_id = ?", (node_id,))
|
||||
conn.execute("DELETE FROM nodes WHERE id = ?", (node_id,))
|
||||
|
||||
def upsert_instance(self, instance: dict[str, Any]) -> None:
|
||||
"""插入或更新节点实例状态记录。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -170,6 +195,7 @@ class Database:
|
||||
)
|
||||
|
||||
def list_instances(self) -> list[dict[str, Any]]:
|
||||
"""按启动时间倒序返回全部节点实例。"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM node_instances ORDER BY started_at DESC"
|
||||
@@ -177,10 +203,12 @@ class Database:
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_instance(self, instance_id: str) -> None:
|
||||
"""删除指定实例记录。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute("DELETE FROM node_instances WHERE id = ?", (instance_id,))
|
||||
|
||||
def upsert_workflow(self, workflow: dict[str, Any]) -> None:
|
||||
"""插入或更新工作流概要信息。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -202,23 +230,28 @@ class Database:
|
||||
)
|
||||
|
||||
def get_workflow(self, workflow_id: str) -> dict[str, Any] | None:
|
||||
"""按 ID 读取工作流概要。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_workflows(self) -> list[dict[str, Any]]:
|
||||
"""按创建时间倒序返回全部工作流。"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM workflows ORDER BY created_at DESC").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_workflow(self, workflow_id: str) -> None:
|
||||
"""级联删除工作流相关的产物、任务、版本和概要记录。"""
|
||||
with self._connect() as conn:
|
||||
# 外键没有级联删除配置,手动按依赖顺序清理。
|
||||
conn.execute("DELETE FROM artifacts WHERE run_id IN (SELECT id FROM workflow_runs WHERE workflow_id = ?)", (workflow_id,))
|
||||
conn.execute("DELETE FROM workflow_runs WHERE workflow_id = ?", (workflow_id,))
|
||||
conn.execute("DELETE FROM workflow_versions WHERE workflow_id = ?", (workflow_id,))
|
||||
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
|
||||
def create_workflow_version(self, workflow_id: str, version: int, definition: dict[str, Any]) -> None:
|
||||
"""为工作流新增一个版本,definition 以 JSON 保存。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -229,6 +262,7 @@ class Database:
|
||||
)
|
||||
|
||||
def get_latest_workflow_version(self, workflow_id: str) -> dict[str, Any] | None:
|
||||
"""返回工作流最新版本,并把 definition_json 反序列化为 definition。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -242,10 +276,12 @@ class Database:
|
||||
if row is None:
|
||||
return None
|
||||
result = dict(row)
|
||||
# 对外统一暴露 definition 字典,隐藏 JSON 存储细节。
|
||||
result["definition"] = json.loads(result.pop("definition_json"))
|
||||
return result
|
||||
|
||||
def get_workflow_version(self, workflow_id: str, version: int) -> dict[str, Any] | None:
|
||||
"""按版本号读取指定工作流版本。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -261,6 +297,7 @@ class Database:
|
||||
return result
|
||||
|
||||
def list_workflow_versions(self, workflow_id: str) -> list[dict[str, Any]]:
|
||||
"""按版本倒序返回工作流全部版本。"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
@@ -278,6 +315,7 @@ class Database:
|
||||
return versions
|
||||
|
||||
def create_run(self, run: dict[str, Any]) -> None:
|
||||
"""创建一条排队中的工作流运行记录。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -302,11 +340,13 @@ class Database:
|
||||
)
|
||||
|
||||
def get_run(self, run_id: str) -> dict[str, Any] | None:
|
||||
"""按 ID 读取任务运行记录。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM workflow_runs WHERE id = ?", (run_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_runs(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""按创建时间倒序返回最近的运行记录。"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?",
|
||||
@@ -315,6 +355,8 @@ class Database:
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def update_run(self, run_id: str, **fields: Any) -> None:
|
||||
"""更新运行状态字段,同时刷新 updated_at;未知字段会被忽略。"""
|
||||
# 只允许更新状态机相关字段,防止任意列被改写。
|
||||
allowed = {
|
||||
"status",
|
||||
"current_node_id",
|
||||
@@ -325,13 +367,16 @@ class Database:
|
||||
if not updates:
|
||||
return
|
||||
updates["updated_at"] = fields.get("updated_at")
|
||||
# 动态拼接 SET 子句,键来自白名单,不存在 SQL 注入风险。
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
values = list(updates.values()) + [run_id]
|
||||
with self._connect() as conn:
|
||||
conn.execute(f"UPDATE workflow_runs SET {assignments} WHERE id = ?", values)
|
||||
|
||||
def reset_run(self, run_id: str, updated_at: str) -> None:
|
||||
"""把失败任务重置为 QUEUED,并清空进度与旧产物,供重试使用。"""
|
||||
with self._connect() as conn:
|
||||
# 清空错误和进度,恢复到首次排队时的状态。
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE workflow_runs
|
||||
@@ -341,9 +386,11 @@ class Database:
|
||||
""",
|
||||
(updated_at, run_id),
|
||||
)
|
||||
# 删除旧产物,避免重试后残留过期下载链接。
|
||||
conn.execute("DELETE FROM artifacts WHERE run_id = ?", (run_id,))
|
||||
|
||||
def next_queued_run(self) -> dict[str, Any] | None:
|
||||
"""按创建时间返回最早一条排队任务,供调度器轮询。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -356,6 +403,7 @@ class Database:
|
||||
return dict(row) if row else None
|
||||
|
||||
def create_artifact(self, artifact: dict[str, Any]) -> None:
|
||||
"""记录任务产物;同 run 与 name 冲突时覆盖。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -375,6 +423,7 @@ class Database:
|
||||
)
|
||||
|
||||
def list_artifacts(self, run_id: str) -> list[dict[str, Any]]:
|
||||
"""按创建时间返回任务的全部产物。"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at",
|
||||
@@ -383,6 +432,7 @@ class Database:
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_artifact(self, run_id: str, name: str) -> dict[str, Any] | None:
|
||||
"""按任务与产物名读取单个产物记录。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM artifacts WHERE run_id = ? AND name = ?",
|
||||
@@ -391,5 +441,6 @@ class Database:
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_run_artifacts(self, run_id: str) -> None:
|
||||
"""删除任务的全部产物记录。"""
|
||||
with self._connect() as conn:
|
||||
conn.execute("DELETE FROM artifacts WHERE run_id = ?", (run_id,))
|
||||
|
||||
+17
@@ -1,3 +1,9 @@
|
||||
"""FastAPI 应用入口。
|
||||
|
||||
负责组装数据库、节点管理器、调度器与静态前端,并在应用生命周期内管理
|
||||
后台线程的启动与清理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -18,27 +24,35 @@ from app.seed import seed_demo_workflow, seed_nodes
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期:启动时初始化存储、种子数据和后台服务,退出时回收资源。"""
|
||||
# 确保数据与存储目录存在,避免首次启动写文件失败。
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
db = Database(DB_PATH)
|
||||
manager = NodeManager(db)
|
||||
# 启动节点空闲回收线程,负责按 TTL 停止空闲节点进程。
|
||||
manager.start_reaper()
|
||||
# 默认自动注册工作区内的节点并创建演示工作流,可关闭便于测试。
|
||||
if os.getenv("WOV_AUTO_SEED", "1") == "1":
|
||||
seed_nodes(db, WORKSPACE_ROOT)
|
||||
seed_demo_workflow(db)
|
||||
scheduler = WorkflowScheduler(db, manager, STORAGE_DIR)
|
||||
# 调度器默认开启,处理排队中的任务;测试可关闭后手动执行。
|
||||
if os.getenv("WOV_SCHEDULER_ENABLED", "1") == "1":
|
||||
scheduler.start()
|
||||
# 共享对象挂到 app.state,路由通过 Depends 延迟获取。
|
||||
app.state.db = db
|
||||
app.state.node_manager = manager
|
||||
app.state.scheduler = scheduler
|
||||
yield
|
||||
# 退出时先停调度器,再关闭全部节点进程,避免残留进程。
|
||||
scheduler.stop()
|
||||
manager.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(title="WOV API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# MVP 阶段不做鉴权,允许跨域便于本地调试与静态页面访问。
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -51,10 +65,13 @@ app.include_router(instances.router)
|
||||
app.include_router(workflows.router)
|
||||
app.include_router(apps.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
"""进程存活探针,供部署环境与前端检测后端可用性。"""
|
||||
return {"status": "ok", "service": "wov-api"}
|
||||
|
||||
|
||||
# 静态前端目录位于工作区下的 wov-web,由 FastAPI 直接挂载。
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "wov-web"
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
"""节点生命周期管理器。
|
||||
|
||||
NodeManager 是节点实例生命周期的唯一所有者:负责按 Manifest 启动节点进程、
|
||||
等待就绪、复用空闲实例、空闲回收和调用转发。API 与调度器不得绕过本模块
|
||||
直接启动或杀死节点进程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -26,25 +33,36 @@ from app.db import Database
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 格式字符串,用于统一时间戳存储。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _idle_seconds(last_used_at: str) -> float:
|
||||
"""计算实例距上次使用的空闲秒数;非法时间按 0 处理。"""
|
||||
if not last_used_at:
|
||||
return 0.0
|
||||
try:
|
||||
last = datetime.fromisoformat(last_used_at)
|
||||
return max(0.0, (datetime.now(timezone.utc) - last).total_seconds())
|
||||
except ValueError:
|
||||
# 时间格式损坏时保守返回 0,避免误回收实例。
|
||||
return 0.0
|
||||
|
||||
|
||||
def _cuda_library_dirs(repo_dir: Path) -> list[Path]:
|
||||
"""在节点虚拟环境中查找 nvidia cublas/cudnn 动态库目录。
|
||||
|
||||
faster-whisper 通过 pip 安装的 CUDA 库位于
|
||||
.venv/.../site-packages/nvidia/<包>/lib(Linux)或 bin(Windows),
|
||||
需要把这些目录加入动态库搜索路径才能被加载。
|
||||
"""
|
||||
venv = repo_dir / ".venv"
|
||||
site_packages: list[Path] = []
|
||||
# Windows 的 site-packages 位于 Lib 下。
|
||||
windows_site = venv / "Lib" / "site-packages"
|
||||
if windows_site.is_dir():
|
||||
site_packages.append(windows_site)
|
||||
# Linux 使用 lib/python3.x/site-packages 结构。
|
||||
site_packages.extend((venv / "lib").glob("python3*/site-packages"))
|
||||
|
||||
dirs: list[Path] = []
|
||||
@@ -58,42 +76,61 @@ def _cuda_library_dirs(repo_dir: Path) -> list[Path]:
|
||||
|
||||
|
||||
def _with_cuda_library_path(env: dict[str, str], repo_dir: Path) -> dict[str, str]:
|
||||
"""把 CUDA 动态库目录注入进程环境变量并去重。"""
|
||||
dirs = [str(path) for path in _cuda_library_dirs(repo_dir)]
|
||||
if not dirs:
|
||||
return env
|
||||
# Windows 动态库搜索走 PATH,Linux 走 LD_LIBRARY_PATH。
|
||||
var = "PATH" if os.name == "nt" else "LD_LIBRARY_PATH"
|
||||
existing = [item for item in env.get(var, "").split(os.pathsep) if item]
|
||||
# 只追加尚未存在的目录,避免重复路径拖慢加载。
|
||||
additions = [path for path in dirs if path not in existing]
|
||||
if additions:
|
||||
# 新目录放在最前,优先使用虚拟环境内的 CUDA 库。
|
||||
env[var] = os.pathsep.join(additions + existing)
|
||||
return env
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeRuntime:
|
||||
"""运行中的节点实例内存态:进程句柄、状态、地址与使用计数。"""
|
||||
|
||||
# 实例唯一 ID,格式 ni_xxxx。
|
||||
instance_id: str
|
||||
# 所属节点注册 ID。
|
||||
node_id: str
|
||||
# 启动该实例使用的 manifest 快照。
|
||||
manifest: NodeManifest
|
||||
# 生命周期状态:starting / ready / stopping / stopped / error。
|
||||
status: str = "starting"
|
||||
# 节点子进程句柄,用于终止与回收。
|
||||
process: subprocess.Popen | None = None
|
||||
pid: int | None = None
|
||||
# 节点 HTTP 服务地址,例如 http://127.0.0.1:xxxx。
|
||||
address: str | None = None
|
||||
started_at: str = field(default_factory=_now_iso)
|
||||
last_used_at: str = field(default_factory=_now_iso)
|
||||
# 当前并发占用数,超过 manifest.max_concurrency 不再复用。
|
||||
busy_count: int = 0
|
||||
error: str | None = None
|
||||
# 最近 200 行 stderr,便于排查启动失败。
|
||||
stderr_tail: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class NodeManager:
|
||||
"""节点进程管理器:启动、复用、调用与回收的唯一入口。"""
|
||||
|
||||
def __init__(self, db: Database) -> None:
|
||||
"""保存数据库引用并初始化运行时表与回收线程控制字段。"""
|
||||
self.db = db
|
||||
self._runtimes: dict[str, NodeRuntime] = {}
|
||||
# 可重入锁保护运行时表,避免回收线程与调用线程竞争。
|
||||
self._lock = threading.RLock()
|
||||
self._reaper_thread: threading.Thread | None = None
|
||||
self._stopping = False
|
||||
|
||||
def start_reaper(self) -> None:
|
||||
"""启动后台回收线程,周期性回收空闲节点。"""
|
||||
self._stopping = False
|
||||
self._reaper_thread = threading.Thread(
|
||||
target=self._reaper_loop,
|
||||
@@ -103,18 +140,22 @@ class NodeManager:
|
||||
self._reaper_thread.start()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""停止所有节点实例,供应用退出时清理。"""
|
||||
self._stopping = True
|
||||
with self._lock:
|
||||
for runtime in list(self._runtimes.values()):
|
||||
self._stop_locked(runtime)
|
||||
|
||||
def _reaper_loop(self) -> None:
|
||||
"""后台循环:每隔固定间隔检查并回收满足 TTL 的空闲实例。"""
|
||||
while not self._stopping:
|
||||
time.sleep(NODE_REAP_INTERVAL_SECONDS)
|
||||
with self._lock:
|
||||
for runtime in list(self._runtimes.values()):
|
||||
# 未就绪或正在被调用的实例不回收。
|
||||
if runtime.status != "ready" or runtime.busy_count > 0:
|
||||
continue
|
||||
# 常驻节点永不自动回收。
|
||||
if runtime.manifest.keep_warm:
|
||||
continue
|
||||
ttl = runtime.manifest.idle_ttl_seconds
|
||||
@@ -123,9 +164,11 @@ class NodeManager:
|
||||
self._stop_locked(runtime)
|
||||
|
||||
def _resolve_command(self, manifest: NodeManifest) -> list[str]:
|
||||
"""解析启动命令;python 命令替换为节点仓库虚拟环境解释器。"""
|
||||
command = list(manifest.command)
|
||||
if command and command[0].lower() in {"python", "python3"}:
|
||||
repo_dir = WORKSPACE_ROOT / manifest.repo_dir
|
||||
# 同时兼容 Windows 与 Linux 的虚拟环境路径。
|
||||
windows_python = repo_dir / ".venv" / "Scripts" / "python.exe"
|
||||
unix_python = repo_dir / ".venv" / "bin" / "python"
|
||||
if windows_python.is_file():
|
||||
@@ -133,20 +176,25 @@ class NodeManager:
|
||||
elif unix_python.is_file():
|
||||
command[0] = str(unix_python)
|
||||
else:
|
||||
# 虚拟环境不存在时使用 API 自身的 Python,便于演示。
|
||||
command[0] = sys.executable
|
||||
return command
|
||||
|
||||
def _node_env(self, manifest: NodeManifest) -> dict[str, str]:
|
||||
"""构造节点进程环境:注入 SDK 路径、manifest 环境变量与 CUDA 路径。"""
|
||||
env = os.environ.copy()
|
||||
current_pythonpath = env.get("PYTHONPATH", "")
|
||||
# 把 wov-sdk 源码目录放到 PYTHONPATH 最前。
|
||||
env["PYTHONPATH"] = os.pathsep.join(
|
||||
item for item in [str(SDK_SRC), current_pythonpath] if item
|
||||
)
|
||||
env.update(manifest.env)
|
||||
# 为 faster-whisper 等节点补充 CUDA 动态库搜索路径。
|
||||
repo_dir = WORKSPACE_ROOT / manifest.repo_dir
|
||||
return _with_cuda_library_path(env, repo_dir)
|
||||
|
||||
def _start_locked(self, manifest: NodeManifest) -> NodeRuntime:
|
||||
"""启动单个节点进程并等待就绪;调用方必须持有锁。"""
|
||||
runtime = NodeRuntime(
|
||||
instance_id=f"ni_{uuid.uuid4().hex[:12]}",
|
||||
node_id=manifest.id,
|
||||
@@ -154,11 +202,13 @@ class NodeManager:
|
||||
)
|
||||
repo_dir = WORKSPACE_ROOT / manifest.repo_dir
|
||||
if not repo_dir.is_dir():
|
||||
# 仓库目录缺失时直接标记错误,避免后续命令误导。
|
||||
runtime.status = "error"
|
||||
runtime.error = f"repo_dir not found: {repo_dir}"
|
||||
self.db.upsert_instance(self._instance_row(runtime))
|
||||
return runtime
|
||||
|
||||
# 子进程 stdout 读取线程解析到端口后设置事件。
|
||||
ready_event = threading.Event()
|
||||
ready_port: dict[str, int] = {}
|
||||
|
||||
@@ -174,6 +224,7 @@ class NodeManager:
|
||||
bufsize=1,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 命令不存在等启动错误统一记录为 error。
|
||||
runtime.status = "error"
|
||||
runtime.error = str(exc)
|
||||
self.db.upsert_instance(self._instance_row(runtime))
|
||||
@@ -183,17 +234,21 @@ class NodeManager:
|
||||
runtime.pid = process.pid
|
||||
|
||||
def read_stdout() -> None:
|
||||
"""逐行读取子进程 stdout,解析 WOV_NODE_READY 端口。"""
|
||||
assert process.stdout is not None
|
||||
for line in iter(process.stdout.readline, ""):
|
||||
line = line.strip()
|
||||
if line.startswith("WOV_NODE_READY"):
|
||||
try:
|
||||
# 从 "port=<数字>" 中提取端口。
|
||||
ready_port["port"] = int(line.split("port=", 1)[1])
|
||||
except (IndexError, ValueError):
|
||||
# 格式异常时按 0 处理,随后会判定为未就绪。
|
||||
ready_port["port"] = 0
|
||||
ready_event.set()
|
||||
|
||||
def read_stderr() -> None:
|
||||
"""缓存子进程 stderr 尾部,供失败诊断使用。"""
|
||||
assert process.stderr is not None
|
||||
for line in iter(process.stderr.readline, ""):
|
||||
line = line.strip()
|
||||
@@ -204,6 +259,7 @@ class NodeManager:
|
||||
threading.Thread(target=read_stdout, daemon=True).start()
|
||||
threading.Thread(target=read_stderr, daemon=True).start()
|
||||
|
||||
# 等待就绪行,超时或端口无效则终止进程并标记失败。
|
||||
ready = ready_event.wait(timeout=NODE_READY_TIMEOUT_SECONDS)
|
||||
if not ready or "port" not in ready_port or not ready_port["port"]:
|
||||
self._stop_process(process)
|
||||
@@ -212,12 +268,14 @@ class NodeManager:
|
||||
self.db.upsert_instance(self._instance_row(runtime))
|
||||
return runtime
|
||||
|
||||
# 端口有效后做一次真实 HTTP 健康检查。
|
||||
address = f"http://127.0.0.1:{ready_port['port']}"
|
||||
try:
|
||||
with urllib.request.urlopen(f"{address}/health", timeout=2) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"health check returned {response.status}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 健康检查失败说明进程虽然打印了就绪行但服务不可用。
|
||||
self._stop_process(process)
|
||||
runtime.status = "error"
|
||||
runtime.error = f"health check failed: {exc}"
|
||||
@@ -233,25 +291,31 @@ class NodeManager:
|
||||
return runtime
|
||||
|
||||
def _stop_process(self, process: subprocess.Popen) -> None:
|
||||
"""先优雅 terminate,超时后强制 kill。"""
|
||||
# 进程已退出时无需处理。
|
||||
if process.poll() is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
# 优雅退出失败时强制结束,避免僵尸进程占用端口。
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
def _stop_locked(self, runtime: NodeRuntime) -> None:
|
||||
"""停止实例进程并更新数据库状态;调用方必须持有锁。"""
|
||||
if runtime.process is not None:
|
||||
self._stop_process(runtime.process)
|
||||
runtime.status = "stopped"
|
||||
runtime.pid = None
|
||||
runtime.address = None
|
||||
# 从内存运行时表中移除,后续调用会重新启动。
|
||||
self._runtimes.pop(runtime.instance_id, None)
|
||||
self.db.upsert_instance(self._instance_row(runtime))
|
||||
|
||||
def _instance_row(self, runtime: NodeRuntime) -> dict:
|
||||
"""把运行时对象转换为数据库实例记录。"""
|
||||
return {
|
||||
"id": runtime.instance_id,
|
||||
"node_id": runtime.node_id,
|
||||
@@ -265,11 +329,13 @@ class NodeManager:
|
||||
}
|
||||
|
||||
def acquire(self, node_id: str) -> tuple[NodeRuntime, str]:
|
||||
"""获取一个可用节点实例:优先复用空闲实例,否则启动新实例。"""
|
||||
manifest = self.db.get_node(node_id)
|
||||
if manifest is None:
|
||||
raise ValueError(f"node not registered: {node_id}")
|
||||
|
||||
with self._lock:
|
||||
# 优先复用未达并发上限的就绪实例。
|
||||
for runtime in self._runtimes.values():
|
||||
if (
|
||||
runtime.node_id == node_id
|
||||
@@ -280,24 +346,30 @@ class NodeManager:
|
||||
runtime.last_used_at = _now_iso()
|
||||
return runtime, runtime.address or ""
|
||||
|
||||
# 没有可复用实例时启动新进程。
|
||||
runtime = self._start_locked(manifest)
|
||||
if runtime.status != "ready":
|
||||
raise RuntimeError(runtime.error or "node failed to start")
|
||||
# 新实例占用一个并发槽位。
|
||||
runtime.busy_count += 1
|
||||
runtime.last_used_at = _now_iso()
|
||||
return runtime, runtime.address or ""
|
||||
|
||||
def release(self, instance_id: str) -> None:
|
||||
"""释放一次实例占用;由 invoke 的 finally 保证执行。"""
|
||||
with self._lock:
|
||||
runtime = self._runtimes.get(instance_id)
|
||||
if runtime is None:
|
||||
return
|
||||
# 计数下限为 0,防止重复释放导致负值。
|
||||
runtime.busy_count = max(0, runtime.busy_count - 1)
|
||||
runtime.last_used_at = _now_iso()
|
||||
self.db.upsert_instance(self._instance_row(runtime))
|
||||
|
||||
def invoke(self, node_id: str, request: InvokeRequest) -> InvokeResponse:
|
||||
"""向节点实例发起一次调用,并保证无论成败都释放实例。"""
|
||||
runtime, address = self.acquire(node_id)
|
||||
# 回填实际实例 ID,供节点与日志追踪。
|
||||
request.node_instance_id = runtime.instance_id
|
||||
body = json.dumps(request.to_dict(), ensure_ascii=False).encode("utf-8")
|
||||
http_request = urllib.request.Request(
|
||||
@@ -307,21 +379,25 @@ class NodeManager:
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
# 超时来自配置,避免 LLM 等慢节点被过早中断。
|
||||
with urllib.request.urlopen(http_request, timeout=NODE_INVOKE_TIMEOUT_SECONDS) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
return InvokeResponse.from_dict(payload)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
# 节点返回 500 时 body 仍是协议 JSON,尝试解析失败原因。
|
||||
payload = json.loads(exc.read().decode("utf-8"))
|
||||
return InvokeResponse.from_dict(payload)
|
||||
except Exception: # noqa: BLE001
|
||||
return InvokeResponse(status="failed", error=f"node returned {exc.code}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 网络错误、超时等统一转换为 failed。
|
||||
return InvokeResponse(status="failed", error=str(exc))
|
||||
finally:
|
||||
self.release(request.node_instance_id)
|
||||
|
||||
def stop_instance(self, instance_id: str) -> None:
|
||||
"""按实例 ID 停止指定节点,供管理后台使用。"""
|
||||
with self._lock:
|
||||
runtime = self._runtimes.get(instance_id)
|
||||
if runtime is None:
|
||||
@@ -329,6 +405,7 @@ class NodeManager:
|
||||
self._stop_locked(runtime)
|
||||
|
||||
def stop_all_for_node(self, node_id: str) -> None:
|
||||
"""停止某个节点的全部实例,用于删除节点前清理。"""
|
||||
with self._lock:
|
||||
for runtime in list(self._runtimes.values()):
|
||||
if runtime.node_id == node_id:
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
"""WOV API routers."""
|
||||
"""WOV API 路由包。
|
||||
|
||||
按职责拆分为节点、实例、工作流和用户应用四组路由,统一由 app.main 挂载。
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""用户端应用路由。
|
||||
|
||||
面向普通用户暴露“应用中心”能力:列出已发布工作流、上传输入创建任务、
|
||||
查询进度、重试失败任务以及下载产物。用户只看到输入 -> 进度 -> 结果。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -13,10 +19,12 @@ router = APIRouter(tags=["apps"])
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 格式字符串。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _get_db() -> Database:
|
||||
"""从 FastAPI 应用状态中延迟获取数据库实例。"""
|
||||
from app.main import app
|
||||
|
||||
return app.state.db
|
||||
@@ -24,8 +32,10 @@ def _get_db() -> Database:
|
||||
|
||||
@router.get("/api/apps")
|
||||
def list_apps(db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回全部已发布工作流及其最新版本定义。"""
|
||||
apps = []
|
||||
for workflow in db.list_workflows():
|
||||
# 草稿工作流不对用户端可见。
|
||||
if not workflow["published"]:
|
||||
continue
|
||||
latest = db.get_latest_workflow_version(workflow["id"])
|
||||
@@ -47,7 +57,9 @@ async def create_run(
|
||||
file: UploadFile = File(...),
|
||||
db: Database = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""接收用户上传文件,创建排队中的工作流任务。"""
|
||||
workflow = db.get_workflow(workflow_id)
|
||||
# 只允许对已发布且存在版本的工作流发起任务。
|
||||
if workflow is None or not workflow["published"]:
|
||||
raise HTTPException(status_code=404, detail="published workflow not found")
|
||||
|
||||
@@ -56,9 +68,11 @@ async def create_run(
|
||||
raise HTTPException(status_code=422, detail="workflow has no version")
|
||||
|
||||
run_id = f"run_{uuid.uuid4().hex[:12]}"
|
||||
# 使用安全文件名,避免路径穿越。
|
||||
filename = Path(file.filename or "upload.bin").name
|
||||
from app.config import STORAGE_DIR
|
||||
|
||||
# 上传文件按 run 隔离存放,调度器通过 input_uri 引用。
|
||||
input_dir = STORAGE_DIR / "uploads" / run_id
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
input_uri = input_dir / filename
|
||||
@@ -88,11 +102,13 @@ async def create_run(
|
||||
|
||||
@router.get("/api/runs")
|
||||
def list_runs(db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回最近的运行记录,供任务管理页展示。"""
|
||||
return db.list_runs()
|
||||
|
||||
|
||||
@router.get("/api/runs/{run_id}")
|
||||
def get_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""返回任务详情,并附带当前产物列表。"""
|
||||
run = db.get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
@@ -102,17 +118,20 @@ def get_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
|
||||
@router.post("/api/runs/{run_id}/retry")
|
||||
def retry_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""重置失败任务为排队状态,清空旧产物后重新执行。"""
|
||||
run = db.get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
if run["status"] != "FAILED":
|
||||
raise HTTPException(status_code=422, detail="only failed runs can be retried")
|
||||
# reset_run 会清空进度、错误和旧产物,确保从头开始。
|
||||
db.reset_run(run_id, _now_iso())
|
||||
return {"id": run_id, "status": "QUEUED"}
|
||||
|
||||
|
||||
@router.get("/api/runs/{run_id}/artifacts")
|
||||
def list_run_artifacts(run_id: str, db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回任务全部产物记录。"""
|
||||
if db.get_run(run_id) is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
return db.list_artifacts(run_id)
|
||||
@@ -124,6 +143,7 @@ def download_artifact(
|
||||
artifact_name: str,
|
||||
db: Database = Depends(_get_db),
|
||||
) -> FileResponse:
|
||||
"""按任务与产物名下载文件,文件缺失时返回 404。"""
|
||||
artifact = db.get_artifact(run_id, artifact_name)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=404, detail="artifact not found")
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""节点实例管理路由。
|
||||
|
||||
提供管理后台查看节点实例与手动停止实例的能力。实例生命周期仍由
|
||||
NodeManager 控制,路由只转发停止请求。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -9,12 +15,14 @@ router = APIRouter(prefix="/api/admin/node-instances", tags=["node-instances"])
|
||||
|
||||
|
||||
def _get_db() -> Database:
|
||||
"""从应用状态延迟获取数据库实例。"""
|
||||
from app.main import app
|
||||
|
||||
return app.state.db
|
||||
|
||||
|
||||
def _get_manager() -> NodeManager:
|
||||
"""从应用状态延迟获取节点管理器。"""
|
||||
from app.main import app
|
||||
|
||||
return app.state.node_manager
|
||||
@@ -22,6 +30,7 @@ def _get_manager() -> NodeManager:
|
||||
|
||||
@router.get("")
|
||||
def list_instances(db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回全部节点实例记录。"""
|
||||
return db.list_instances()
|
||||
|
||||
|
||||
@@ -31,6 +40,7 @@ def stop_instance(
|
||||
db: Database = Depends(_get_db),
|
||||
manager: NodeManager = Depends(_get_manager),
|
||||
) -> dict:
|
||||
"""请求停止指定实例;停止后实例记录保留为 stopped 状态。"""
|
||||
manager.stop_instance(instance_id)
|
||||
instance = next(
|
||||
(item for item in db.list_instances() if item["id"] == instance_id),
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""节点管理路由。
|
||||
|
||||
提供节点注册、查询、删除和手动调用接口。注册数据进入 SQLite 节点注册表,
|
||||
实际进程启动与回收仍由 NodeManager 负责。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -11,12 +17,14 @@ router = APIRouter(prefix="/api/admin/nodes", tags=["nodes"])
|
||||
|
||||
|
||||
def _get_db() -> Database:
|
||||
"""从应用状态延迟获取数据库实例。"""
|
||||
from app.main import app
|
||||
|
||||
return app.state.db
|
||||
|
||||
|
||||
def _get_manager() -> NodeManager:
|
||||
"""从应用状态延迟获取节点管理器。"""
|
||||
from app.main import app
|
||||
|
||||
return app.state.node_manager
|
||||
@@ -24,8 +32,10 @@ def _get_manager() -> NodeManager:
|
||||
|
||||
@router.post("")
|
||||
def register_node(payload: NodeCreate, db: Database = Depends(_get_db)) -> dict:
|
||||
"""校验并注册节点,返回注册后的 manifest。"""
|
||||
manifest = payload.to_manifest()
|
||||
try:
|
||||
# 协议级校验保证注册表内数据始终合法。
|
||||
manifest.validate()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
@@ -35,11 +45,13 @@ def register_node(payload: NodeCreate, db: Database = Depends(_get_db)) -> dict:
|
||||
|
||||
@router.get("")
|
||||
def list_nodes(db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回全部已注册节点。"""
|
||||
return [manifest.to_dict() for manifest in db.list_nodes()]
|
||||
|
||||
|
||||
@router.get("/{node_id}")
|
||||
def get_node(node_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""按 ID 返回节点 manifest。"""
|
||||
manifest = db.get_node(node_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(status_code=404, detail="node not found")
|
||||
@@ -52,8 +64,10 @@ def delete_node(
|
||||
db: Database = Depends(_get_db),
|
||||
manager: NodeManager = Depends(_get_manager),
|
||||
) -> dict:
|
||||
"""删除节点前先停止其全部运行实例。"""
|
||||
if db.get_node(node_id) is None:
|
||||
raise HTTPException(status_code=404, detail="node not found")
|
||||
# 先回收进程再删注册记录,避免残留孤儿进程。
|
||||
manager.stop_all_for_node(node_id)
|
||||
db.delete_node(node_id)
|
||||
return {"deleted": node_id}
|
||||
@@ -65,8 +79,10 @@ def invoke_node(
|
||||
payload: InvokePayload,
|
||||
manager: NodeManager = Depends(_get_manager),
|
||||
) -> dict:
|
||||
"""管理后台手动调用节点,产物写入固定输出目录。"""
|
||||
from app.config import STORAGE_DIR
|
||||
|
||||
# 与管理运行共用目录结构,便于调试产物位置。
|
||||
output_dir = (
|
||||
STORAGE_DIR / "runs" / payload.run_id / "steps" / node_id
|
||||
)
|
||||
@@ -86,6 +102,7 @@ def list_node_instances(
|
||||
node_id: str,
|
||||
db: Database = Depends(_get_db),
|
||||
) -> list[dict]:
|
||||
"""返回指定节点的全部实例记录。"""
|
||||
if db.get_node(node_id) is None:
|
||||
raise HTTPException(status_code=404, detail="node not found")
|
||||
return [
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""工作流管理路由。
|
||||
|
||||
提供工作流的创建、查询、校验、发布和删除能力。工作流以版本化 DAG 数据保存,
|
||||
不写死在业务代码中。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@@ -13,17 +19,20 @@ router = APIRouter(prefix="/api/admin/workflows", tags=["workflows"])
|
||||
|
||||
|
||||
def _get_db() -> Database:
|
||||
"""从应用状态延迟获取数据库实例。"""
|
||||
from app.main import app
|
||||
|
||||
return app.state.db
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
"""把工作流名称转换为小写连字符 ID;无有效字符时生成随机 ID。"""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return slug or uuid.uuid4().hex[:8]
|
||||
|
||||
|
||||
def _validate_definition(raw: dict) -> WorkflowDefinition:
|
||||
"""解析并校验 DAG 定义,非法时转换为 422 HTTP 异常。"""
|
||||
try:
|
||||
definition = WorkflowDefinition.from_dict(raw)
|
||||
definition.validate()
|
||||
@@ -34,6 +43,7 @@ def _validate_definition(raw: dict) -> WorkflowDefinition:
|
||||
|
||||
@router.get("")
|
||||
def list_workflows(db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回全部工作流概要。"""
|
||||
return db.list_workflows()
|
||||
|
||||
|
||||
@@ -42,10 +52,13 @@ def create_workflow(
|
||||
payload: WorkflowCreate,
|
||||
db: Database = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""创建新工作流或为已有工作流追加一个版本。"""
|
||||
definition = _validate_definition(payload.definition)
|
||||
# 未显式指定 ID 时由名称生成;已有工作流则版本号递增。
|
||||
workflow_id = payload.id or _slugify(payload.name)
|
||||
existing = db.get_workflow(workflow_id)
|
||||
version = (existing or {}).get("latest_version", 0) + 1
|
||||
# 每次创建都保存新版本,发布操作只切换 published 标记。
|
||||
db.upsert_workflow(
|
||||
{
|
||||
"id": workflow_id,
|
||||
@@ -67,6 +80,7 @@ def create_workflow(
|
||||
|
||||
@router.get("/{workflow_id}")
|
||||
def get_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""返回工作流概要及最新版本定义。"""
|
||||
workflow = db.get_workflow(workflow_id)
|
||||
if workflow is None:
|
||||
raise HTTPException(status_code=404, detail="workflow not found")
|
||||
@@ -77,6 +91,7 @@ def get_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
|
||||
@router.delete("/{workflow_id}")
|
||||
def delete_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""删除工作流及其版本、任务和产物记录。"""
|
||||
if db.get_workflow(workflow_id) is None:
|
||||
raise HTTPException(status_code=404, detail="workflow not found")
|
||||
db.delete_workflow(workflow_id)
|
||||
@@ -89,6 +104,7 @@ def validate_workflow(
|
||||
definition: dict,
|
||||
db: Database = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""在不保存的情况下校验一份 DAG 定义。"""
|
||||
if db.get_workflow(workflow_id) is None:
|
||||
raise HTTPException(status_code=404, detail="workflow not found")
|
||||
parsed = _validate_definition(definition)
|
||||
@@ -97,11 +113,13 @@ def validate_workflow(
|
||||
|
||||
@router.post("/{workflow_id}/publish")
|
||||
def publish_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""把工作流标记为已发布,使其出现在用户应用中心。"""
|
||||
workflow = db.get_workflow(workflow_id)
|
||||
if workflow is None:
|
||||
raise HTTPException(status_code=404, detail="workflow not found")
|
||||
if workflow["latest_version"] == 0:
|
||||
raise HTTPException(status_code=422, detail="workflow has no version")
|
||||
# 发布只是状态切换,不修改已保存的版本数据。
|
||||
db.upsert_workflow(
|
||||
{
|
||||
"id": workflow_id,
|
||||
@@ -116,6 +134,7 @@ def publish_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
|
||||
@router.get("/{workflow_id}/versions")
|
||||
def list_versions(workflow_id: str, db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回工作流全部版本定义。"""
|
||||
if db.get_workflow(workflow_id) is None:
|
||||
raise HTTPException(status_code=404, detail="workflow not found")
|
||||
return db.list_workflow_versions(workflow_id)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""工作流调度器。
|
||||
|
||||
轮询 SQLite 中的排队任务,按工作流 DAG 的拓扑顺序依次调用节点,并把节点
|
||||
产物登记为任务产物。MVP 使用进程内单线程顺序执行,后续可替换为分布式队列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
@@ -13,20 +19,25 @@ from app.node_manager import NodeManager
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 格式字符串。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def topological_sort(definition: WorkflowDefinition) -> list[str]:
|
||||
"""对工作流 DAG 做拓扑排序,返回可执行的节点 ID 顺序。"""
|
||||
nodes = {node.id: node for node in definition.nodes}
|
||||
# 统计每个节点的入度,并记录依赖关系。
|
||||
indegree = {node_id: 0 for node_id in nodes}
|
||||
dependents: dict[str, list[str]] = {node_id: [] for node_id in nodes}
|
||||
|
||||
for edge in definition.edges:
|
||||
# 边引用了不存在的节点时直接报错。
|
||||
if edge.from_node not in nodes or edge.to_node not in nodes:
|
||||
raise ValueError(f"unknown edge: {edge.from_node} -> {edge.to_node}")
|
||||
indegree[edge.to_node] += 1
|
||||
dependents[edge.from_node].append(edge.to_node)
|
||||
|
||||
# Kahn 算法:从入度为 0 的节点开始逐层取出。
|
||||
queue = [node_id for node_id, degree in indegree.items() if degree == 0]
|
||||
ordered: list[str] = []
|
||||
while queue:
|
||||
@@ -37,12 +48,15 @@ def topological_sort(definition: WorkflowDefinition) -> list[str]:
|
||||
if indegree[dependent] == 0:
|
||||
queue.append(dependent)
|
||||
|
||||
# 排序结果数量不足说明存在环,无法确定执行顺序。
|
||||
if len(ordered) != len(nodes):
|
||||
raise ValueError("workflow contains a cycle")
|
||||
return ordered
|
||||
|
||||
|
||||
class WorkflowScheduler:
|
||||
"""后台任务调度器:单线程轮询并执行排队中的工作流运行。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Database,
|
||||
@@ -50,6 +64,7 @@ class WorkflowScheduler:
|
||||
storage_dir: Path,
|
||||
interval_seconds: float = 1.0,
|
||||
) -> None:
|
||||
"""保存依赖并初始化轮询线程控制字段。"""
|
||||
self.db = db
|
||||
self.node_manager = node_manager
|
||||
self.storage_dir = storage_dir
|
||||
@@ -58,6 +73,7 @@ class WorkflowScheduler:
|
||||
self._stopping = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""启动调度线程;重复调用无副作用。"""
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._stopping = False
|
||||
@@ -69,12 +85,14 @@ class WorkflowScheduler:
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""请求停止并等待轮询线程退出。"""
|
||||
self._stopping = True
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
def _loop(self) -> None:
|
||||
"""轮询循环:有排队任务就立即执行,否则休眠一个间隔。"""
|
||||
while not self._stopping:
|
||||
run = self.db.next_queued_run()
|
||||
if run is not None:
|
||||
@@ -88,18 +106,24 @@ class WorkflowScheduler:
|
||||
run_input_uri: str | None,
|
||||
outputs_by_node: dict[str, dict[str, str]],
|
||||
) -> str | None:
|
||||
"""解析输入引用:input.xxx 取任务入口,node.key 取前序节点产物。"""
|
||||
# 入口引用以 input. 为前缀。
|
||||
if ref.startswith("input."):
|
||||
return run_input_uri
|
||||
# 其余引用必须形如 "节点ID.输出名"。
|
||||
node_id, separator, key = ref.partition(".")
|
||||
if not separator:
|
||||
return None
|
||||
return outputs_by_node.get(node_id, {}).get(key)
|
||||
|
||||
def execute_run(self, run_id: str) -> None:
|
||||
"""执行单个任务:加载 DAG、按拓扑顺序调用节点并登记产物。"""
|
||||
run = self.db.get_run(run_id)
|
||||
# 任务不存在或不在排队状态时直接返回,避免重复执行。
|
||||
if run is None or run["status"] != "QUEUED":
|
||||
return
|
||||
|
||||
# 工作流或版本记录丢失时把任务标记为失败。
|
||||
workflow = self.db.get_workflow(run["workflow_id"])
|
||||
if workflow is None:
|
||||
self.db.update_run(run_id, status="FAILED", error="workflow not found", updated_at=_now_iso())
|
||||
@@ -110,6 +134,7 @@ class WorkflowScheduler:
|
||||
self.db.update_run(run_id, status="FAILED", error="workflow version not found", updated_at=_now_iso())
|
||||
return
|
||||
|
||||
# 解析并校验 DAG,随后计算拓扑执行顺序。
|
||||
definition = WorkflowDefinition.from_dict(version["definition"])
|
||||
definition.validate()
|
||||
ordered = topological_sort(definition)
|
||||
@@ -118,6 +143,7 @@ class WorkflowScheduler:
|
||||
|
||||
try:
|
||||
for index, node_id in enumerate(ordered):
|
||||
# 当前节点进度 = 已完成节点数 / 总节点数。
|
||||
node_spec = next(item for item in definition.nodes if item.id == node_id)
|
||||
self.db.update_run(
|
||||
run_id,
|
||||
@@ -125,6 +151,7 @@ class WorkflowScheduler:
|
||||
progress=index / len(ordered),
|
||||
updated_at=_now_iso(),
|
||||
)
|
||||
# 解析节点声明的每个输入引用,缺任一输入即失败。
|
||||
invoke_inputs: dict[str, str] = {}
|
||||
for input_name, ref in node_spec.inputs.items():
|
||||
value = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node)
|
||||
@@ -132,6 +159,7 @@ class WorkflowScheduler:
|
||||
raise ValueError(f"missing input {input_name} for node {node_id}")
|
||||
invoke_inputs[input_name] = value
|
||||
|
||||
# 每个任务的每个节点使用独立产物目录,避免并发冲突。
|
||||
output_dir = (
|
||||
self.storage_dir
|
||||
/ "runs"
|
||||
@@ -149,13 +177,16 @@ class WorkflowScheduler:
|
||||
output_dir=str(output_dir),
|
||||
),
|
||||
)
|
||||
# 节点返回非 completed 即视为步骤失败。
|
||||
if response.status != "completed":
|
||||
raise RuntimeError(response.error or f"node {node_id} failed")
|
||||
|
||||
# 记录节点输出,供后续节点引用和最终产物映射使用。
|
||||
outputs_by_node[node_id] = {
|
||||
str(key): str(value) for key, value in response.outputs.items()
|
||||
}
|
||||
for key, uri in outputs_by_node[node_id].items():
|
||||
# 产物名带节点前缀,例如 asr.srt_uri,避免跨节点重名。
|
||||
artifact = {
|
||||
"run_id": run_id,
|
||||
"node_id": node_id,
|
||||
@@ -166,6 +197,7 @@ class WorkflowScheduler:
|
||||
}
|
||||
self.db.create_artifact(artifact)
|
||||
|
||||
# 处理 final_outputs,为用户端提供简洁的下载别名。
|
||||
for alias, ref in definition.final_outputs.items():
|
||||
resolved = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node)
|
||||
if resolved is not None:
|
||||
@@ -180,6 +212,7 @@ class WorkflowScheduler:
|
||||
}
|
||||
)
|
||||
|
||||
# 全部节点成功后任务标记为完成。
|
||||
self.db.update_run(
|
||||
run_id,
|
||||
status="COMPLETED",
|
||||
@@ -188,6 +221,7 @@ class WorkflowScheduler:
|
||||
updated_at=_now_iso(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 任一步骤异常都结束任务并记录错误,等待用户重试。
|
||||
self.db.update_run(
|
||||
run_id,
|
||||
status="FAILED",
|
||||
@@ -197,6 +231,7 @@ class WorkflowScheduler:
|
||||
|
||||
@staticmethod
|
||||
def _mime_type(uri: str) -> str:
|
||||
"""按扩展名推断产物 MIME 类型,未知类型使用通用二进制类型。"""
|
||||
path = Path(uri)
|
||||
suffix = path.suffix.lower()
|
||||
return {
|
||||
@@ -209,6 +244,7 @@ class WorkflowScheduler:
|
||||
|
||||
@staticmethod
|
||||
def _file_size(uri: str) -> int:
|
||||
"""读取产物文件大小;文件缺失时按 0 处理。"""
|
||||
try:
|
||||
return Path(uri).stat().st_size
|
||||
except OSError:
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""FastAPI 请求/响应 schema。
|
||||
|
||||
使用 Pydantic 模型校验管理 API 的 JSON 请求体,并把请求数据转换为 SDK
|
||||
协议模型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -8,6 +14,8 @@ from wov_sdk.models import NodeManifest
|
||||
|
||||
|
||||
class NodeCreate(BaseModel):
|
||||
"""节点注册请求体,字段与 NodeManifest 保持一致。"""
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
version: str = Field(min_length=1)
|
||||
@@ -23,6 +31,7 @@ class NodeCreate(BaseModel):
|
||||
keep_warm: bool = False
|
||||
|
||||
def to_manifest(self) -> NodeManifest:
|
||||
"""转换为 SDK 的 NodeManifest 对象,供注册与校验使用。"""
|
||||
return NodeManifest(
|
||||
id=self.id,
|
||||
name=self.name,
|
||||
@@ -41,13 +50,20 @@ class NodeCreate(BaseModel):
|
||||
|
||||
|
||||
class InvokePayload(BaseModel):
|
||||
"""管理后台手动调用节点的请求体。"""
|
||||
|
||||
# 默认 run_id 便于快速联调,正式运行时会由调度器生成。
|
||||
run_id: str = Field(default_factory=lambda: "run_admin")
|
||||
inputs: dict[str, Any] = Field(default_factory=dict)
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
"""创建工作流或新增版本的请求体。"""
|
||||
|
||||
# 缺省时由后端根据名称生成 slug ID。
|
||||
id: str | None = None
|
||||
name: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
# DAG 原始字典,后端会解析并校验为 WorkflowDefinition。
|
||||
definition: dict[str, Any]
|
||||
|
||||
+12
@@ -1,3 +1,9 @@
|
||||
"""种子数据模块。
|
||||
|
||||
启动时把工作区内所有 wov-node-* 子仓库的 manifest 注册为节点,并创建演示
|
||||
“视频字幕生成”工作流,方便本地直接体验完整链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
@@ -8,16 +14,21 @@ from app.db import Database
|
||||
|
||||
|
||||
def seed_nodes(db: Database, workspace_root: Path) -> None:
|
||||
"""扫描工作区中的节点仓库并注册其 manifest。"""
|
||||
for node_dir in sorted(workspace_root.glob("wov-node-*")):
|
||||
manifest_path = node_dir / "node.manifest.json"
|
||||
# 没有 manifest 的目录不是节点仓库,直接跳过。
|
||||
if not manifest_path.is_file():
|
||||
continue
|
||||
manifest = NodeManifest.load(str(manifest_path))
|
||||
# 以目录名作为 repo_dir,确保 NodeManager 能定位到子仓库。
|
||||
manifest.repo_dir = node_dir.name
|
||||
db.upsert_node(manifest)
|
||||
|
||||
|
||||
def seed_demo_workflow(db: Database) -> None:
|
||||
"""创建演示工作流:提音 -> 转写 -> 翻译 -> ASS。"""
|
||||
# 已存在同名工作流时不重复创建,保持幂等。
|
||||
if db.get_workflow("demo") is not None:
|
||||
return
|
||||
definition = WorkflowDefinition(
|
||||
@@ -69,4 +80,5 @@ def seed_demo_workflow(db: Database) -> None:
|
||||
"latest_version": 1,
|
||||
}
|
||||
)
|
||||
# 保存第一个版本的 DAG 定义,后续发布流程以版本记录为准。
|
||||
db.create_workflow_version("demo", 1, definition.to_dict())
|
||||
|
||||
Reference in New Issue
Block a user