DataFlowing / processor /app /api /workflows.py
stouchinglhh
collector: 同步采集工程 v1.2.1(全部 LFS 模式文件指针化,与 Space 现有文件合并)
d646dd7
Raw History Blame Contribute Delete
16.4 kB
"""Workflow CRUD, module catalog and async run queue — 本地 JSON(无数据库)。"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from app.auth import get_current_user
from app.localstore import (
list_workflows, get_workflow, upsert_workflow, delete_workflow,
list_runs, get_run, get_episode,
scan_sessions, save_run_if_absent, read_episode_state, set_episode_status,
)
from app.processing.catalog import module_catalog
from app.glove_sources import merge_paired_glove_nodes
from app.workflow_types import migrate_graph_types
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _workflow_project_id(workflow_id: str) -> str | None:
"""Return the owning project for legacy workflows without project_id.
Older workflow records only existed on the project side. Resolving that
relation here keeps direct workflow URLs consistent while the next save
persists the normalized field.
"""
try:
from app.localstore import list_projects
for project in list_projects():
workflow_ids = project.get("workflow_ids") or []
if not isinstance(workflow_ids, list):
workflow_ids = [workflow_ids] if workflow_ids else []
if project.get("workflow_id"):
workflow_ids = [project.get("workflow_id"), *workflow_ids]
if workflow_id in {str(value) for value in workflow_ids if value}:
return str(project.get("id")) if project.get("id") else None
except Exception:
return None
return None
def _bind_workflow_to_project(workflow_id: str, project_id: str | None) -> None:
"""Keep the project-side one-workflow pointer in sync with the relation."""
if not project_id:
return
try:
from app.localstore import list_projects, upsert_project
for project in list_projects():
if str(project.get("id")) != str(project_id):
continue
old_ids = project.get("workflow_ids") or []
if not isinstance(old_ids, list):
old_ids = [old_ids] if old_ids else []
old_id = project.get("workflow_id") or (old_ids[0] if old_ids else None)
project["workflow_id"] = workflow_id
project["workflow_ids"] = [workflow_id]
upsert_project(project)
if old_id and str(old_id) != str(workflow_id):
old_workflow = get_workflow(str(old_id))
if old_workflow and old_workflow.get("project_id") == str(project_id):
old_workflow["project_id"] = None
upsert_workflow(old_workflow)
return
except Exception as exc:
print(f"[Workflows] Project relation sync skipped: {exc}")
def _wf_out(w: dict) -> dict:
graph, _ = migrate_graph_types(w.get("graph") or {})
# Normalize workflows generated before left/right glove channels were
# represented by one physical device node.
merge_paired_glove_nodes(graph)
nodes = graph.get("nodes") or []
# React Flow 节点必需 position:手写/模板 graph 可能缺失,按序号自动布局
for i, node in enumerate(nodes):
if not isinstance(node.get("position"), dict):
node["position"] = {"x": 60 + (i % 3) * 280, "y": 40 + (i // 3) * 200}
graph["nodes"] = nodes
project_id = w.get("project_id") or _workflow_project_id(str(w.get("id")))
return {
"id": w["id"], "name": w["name"], "description": w.get("description"),
"project_id": project_id,
"graph": graph, "node_configs": w.get("node_configs") or {},
"status": w.get("status", "draft"),
"is_preset": bool(w.get("is_preset")),
"node_count": len(nodes), # 前端下拉 empty 标记
"created_at": w.get("created_at"), "updated_at": w.get("updated_at"),
}
def _run_out(r: dict) -> dict:
return {
"id": r["id"], "workflow_id": r.get("workflow_id"),
"episode_id": r.get("episode_id"), "status": r.get("status", "queued"),
"started_at": r.get("started_at"), "finished_at": r.get("finished_at"),
"node_states": r.get("node_states") or {}, "error_log": r.get("error_log"),
"worker_id": r.get("worker_id"), "attempt": r.get("attempt", 0),
"progress": r.get("progress", 0.0), "outputs": r.get("outputs") or {},
"created_at": r.get("created_at"),
}
def _norm_source(value: object) -> str:
return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
def _workflow_input_source_keys(workflow: dict) -> list[str]:
graph = workflow.get("graph") or {}
overrides = workflow.get("node_configs") or {}
keys: list[str] = []
for node in graph.get("nodes", []):
node_id = node.get("id")
data = node.get("data") or {}
config = dict(data.get("config") or {})
override = overrides.get(node_id, {}) if isinstance(overrides, dict) else {}
if isinstance(override, dict):
config.update(override)
values = (config.get("source_keys") or config.get("source_key") or config.get("position"))
if isinstance(values, str):
# 逗号分隔的多源("stereo_left,stereo_right")要拆开,
# 否则上传匹配时拿整串字符串比较,双目数据永远匹配不上。
values = [v.strip() for v in values.split(",") if v.strip()]
if isinstance(values, (list, tuple, set)):
keys.extend(str(value) for value in values if value)
return keys
@router.get("/modules")
def list_modules():
"""Workflow Studio 的模块/卡片目录。"""
return module_catalog()
@router.get("")
def list_workflows_api(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status: str | None = Query(None),
):
workflows = list_workflows()
if status:
workflows = [w for w in workflows if w.get("status") == status]
total = len(workflows)
return {"workflows": [_wf_out(w) for w in workflows[offset:offset + limit]],
"total": total, "limit": limit, "offset": offset}
@router.post("", status_code=201)
def create_workflow(body: dict):
graph, _ = migrate_graph_types(body.get("graph") or {})
wf = {
"id": str(uuid4()),
"name": body.get("name", "Untitled"),
"project_id": str(body["project_id"]) if body.get("project_id") else None,
"description": body.get("description"),
"graph": graph,
"node_configs": body.get("node_configs") or {},
"status": body.get("status", "draft"),
"created_at": _utcnow_iso(),
"updated_at": _utcnow_iso(),
}
merge_paired_glove_nodes(wf["graph"])
upsert_workflow(wf)
_bind_workflow_to_project(wf["id"], wf.get("project_id"))
return _wf_out(wf)
@router.get("/{workflow_id}")
def get_workflow_api(workflow_id: str):
wf = get_workflow(workflow_id)
if wf is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Workflow not found")
return _wf_out(wf)
def _require_preset_edit_perm(wf: dict, current_user: dict) -> None:
"""模板权限:非 admin 不能改/删模板,也不能设置或取消模板标记。"""
if wf.get("is_preset") and current_user.get("role") != "admin":
raise HTTPException(
status_code=403,
detail="Template workflows can only be modified by an admin. Save a copy instead.",
)
@router.put("/{workflow_id}")
def update_workflow_api(
workflow_id: str,
body: dict,
current_user: dict = Depends(get_current_user),
):
wf = get_workflow(workflow_id)
if wf is None:
raise HTTPException(status_code=404, detail="Workflow not found")
_require_preset_edit_perm(wf, current_user)
# 新增/取消预设(提升为预设)仅限超管 —— 非 admin 提交 is_preset 一律拒绝
if "is_preset" in body and current_user.get("role") != "admin":
raise HTTPException(
status_code=403,
detail="Only admins can create or remove preset workflows.",
)
for key in ("name", "project_id", "description", "graph", "node_configs", "status", "is_preset"):
if key in body:
wf[key] = str(body[key]) if key == "project_id" and body[key] else (None if key == "project_id" else body[key])
wf["graph"], _ = migrate_graph_types(wf.get("graph") or {})
merge_paired_glove_nodes(wf.get("graph") or {})
wf["updated_at"] = _utcnow_iso()
upsert_workflow(wf)
_bind_workflow_to_project(wf["id"], wf.get("project_id"))
# 保存 active 工作流后,已绑定项目中从未运行过的历史批次自动回填。
# 运行版本指纹与 backfill 的历史运行过滤保证重复保存不会重复入队。
if wf.get("status") == "active":
try:
from app.localstore import list_projects
from app.workflow_dispatch import backfill_project, project_workflow_ids
for project in list_projects():
if workflow_id in project_workflow_ids(project):
backfill_project(project, [workflow_id])
except Exception as exc:
print(f"[Workflows] Publish backfill skipped: {exc}")
return _wf_out(wf)
@router.delete("/{workflow_id}", status_code=204)
def delete_workflow_api(
workflow_id: str,
current_user: dict = Depends(get_current_user),
):
wf = get_workflow(workflow_id)
if wf is None:
raise HTTPException(status_code=404, detail="Workflow not found")
_require_preset_edit_perm(wf, current_user)
delete_workflow(workflow_id)
def _build_run_record(workflow: dict, episode_id: str, *, project_id: str | None = None,
workflow_revision: str | None = None,
trigger: str = "") -> dict:
graph = workflow.get("graph") or {}
states = {
node.get("id"): {"type": node.get("data", {}).get("nodeType", ""), "status": "queued"}
for node in graph.get("nodes", []) if node.get("id")
}
run = {
"id": str(uuid4()),
"workflow_id": workflow["id"],
"episode_id": episode_id,
"status": "queued",
"node_states": states,
"outputs": {},
"attempt": 0,
"progress": 0.0,
# Worker 执行必需:graph + node_configs + 名称
"workflow_name": workflow.get("name"),
"graph": graph,
"node_configs": workflow.get("node_configs") or {},
"created_at": _utcnow_iso(),
}
if project_id:
run["project_id"] = project_id
if workflow_revision:
run["workflow_revision"] = workflow_revision
if trigger:
run["trigger"] = trigger
return run
def _enqueue_run(workflow: dict, episode_id: str, *, project_id: str | None = None,
workflow_revision: str | None = None,
trigger: str = "", force_rerun: bool = False) -> dict:
"""兼容旧调用的入队入口;新派发链路传入版本指纹做幂等。"""
if not workflow_revision:
from app.workflow_dispatch import workflow_revision as _revision
workflow_revision = _revision(workflow, {})
run = _build_run_record(
workflow, episode_id, project_id=project_id,
workflow_revision=workflow_revision, trigger=trigger,
)
if force_rerun:
try:
from app.ai_annotation import invalidate_ai_annotation_tasks
invalidate_ai_annotation_tasks(episode_id)
except Exception as exc:
print(f"[Workflows] AI task invalidation skipped: {exc}")
from app.localstore import save_annotations
save_annotations(episode_id, [])
previous_status = read_episode_state(episode_id).get("status")
if previous_status != "processing":
set_episode_status(episode_id, "processing")
try:
saved, created = save_run_if_absent(
run,
allow_completed_rerun=force_rerun,
supersede_active=force_rerun,
)
except Exception:
if read_episode_state(episode_id).get("status") == "processing":
set_episode_status(episode_id, previous_status or "to_review")
raise
if (not created and saved.get("status") in ("completed", "failed")
and read_episode_state(episode_id).get("status") == "processing"):
set_episode_status(episode_id, previous_status or "to_review")
return saved
@router.get("/{workflow_id}/usage")
def workflow_usage_api(workflow_id: str):
"""哪些项目绑定了该工作流(Studio 改全局时提示影响范围)。"""
from fastapi import HTTPException
from app.localstore import list_projects
wf = get_workflow(workflow_id)
if wf is None:
raise HTTPException(status_code=404, detail="Workflow not found")
used_by = []
for p in list_projects():
ids = p.get("workflow_id")
if ids:
ids = [str(ids)]
else:
ids = p.get("workflow_ids") or []
if not isinstance(ids, list):
ids = [ids] if ids else []
ids = [str(i) for i in ids if i]
if workflow_id in ids:
used_by.append({"id": p["id"], "name": p.get("name")})
return {"workflow_id": workflow_id, "project_count": len(used_by), "projects": used_by}
@router.post("/{workflow_id}/run", status_code=202)
def run_workflow_api(
workflow_id: str,
episode_id: str | None = Query(None, description="批次名;省略用最新"),
):
from fastapi import HTTPException
wf = get_workflow(workflow_id)
if wf is None:
raise HTTPException(status_code=404, detail="Workflow not found")
if episode_id:
ep = get_episode(episode_id)
if ep is None:
raise HTTPException(status_code=404, detail="Episode not found")
else:
episodes = scan_sessions()
if not episodes:
raise HTTPException(status_code=404, detail="No episode available")
ep = episodes[-1]
episode_id = ep["id"]
# Keep the manual Studio run on the same type-first resolver as upload and
# Review reprocess. Otherwise this legacy endpoint could bypass the fixed
# input-category contract and run a stale source_key from an old graph.
from app.workflow_dispatch import enqueue_workflow_once
result = enqueue_workflow_once(
wf, ep, trigger="manual_run", force_rerun=True,
)
if result.get("status") == "skipped":
raise HTTPException(
status_code=409,
detail="No connected workflow input matches this episode",
)
run = result["run"]
return _run_out(run)
@router.get("/{workflow_id}/runs")
def list_runs_api(workflow_id: str, limit: int = Query(20, ge=1, le=100)):
runs = [r for r in list_runs() if r.get("workflow_id") == workflow_id]
runs.sort(key=lambda r: r.get("created_at") or "", reverse=True)
return [_run_out(r) for r in runs[:limit]]
@router.get("/runs/{run_id}")
def get_run_api(run_id: str):
from fastapi import HTTPException
run = get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail="Workflow run not found")
return _run_out(run)
@router.post("/runs/{run_id}/retry", status_code=202)
def retry_run_api(run_id: str):
from fastapi import HTTPException
previous = get_run(run_id)
if previous is None:
raise HTTPException(status_code=404, detail="Workflow run not found")
if previous.get("status") != "failed":
raise HTTPException(status_code=409, detail="Only failed runs can be retried")
wf = get_workflow(previous.get("workflow_id"))
ep = get_episode(previous.get("episode_id"))
if wf is None or ep is None:
raise HTTPException(status_code=404, detail="Workflow or episode no longer exists")
from app.workflow_dispatch import enqueue_workflow_once
result = enqueue_workflow_once(
wf, ep, trigger="run_retry", force_rerun=True,
)
if result.get("status") == "skipped":
raise HTTPException(
status_code=409,
detail="No connected workflow input matches this episode",
)
return _run_out(result["run"])