agent-Specialization/modules/workflow_manager.py
JOJO a2cf547400 feat(i18n): 后端用户可见消息国际化(zh/en 双语 + ui_locale 偏好持久化)
- 新增 modules/i18n.py:tr() + 进程级语言缓存 + modules/i18n_messages/ 域文案包自动聚合
- 新增 29 个域文案包,共 1153 条双语 key;90+ 源文件 1146 处用户可见消息 tr 化
- ui_locale 存入 personalization.json(用户级共享),前后端双向同步
- 前端匹配点双语兼容(history/shared/ChatArea/taskPolling/upload 等正则)
- 修复语言判等陷阱:审批等待加稳定 code 字段;conversation.py 不存在判等改双语 helper
- 边界:日志/prompt 注入/子智能体工具回填/容器内嵌脚本不迁移
2026-08-29 07:58:29 +08:00

447 lines
18 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.

"""工作流Workflow存储管理WORKFLOW.md 的加载、保存、列举、删除。
存储布局(对齐 skills 的双源合并模式):
- 内置示例:源码树 ``workflows/<name>/WORKFLOW.md``(只读种子)
- 用户库host 模式 ``<runtime_root>/host/workflows/``web/docker 模式
``users/<user>/personal/workflows/``(从 workspace data_dir 推断)
文件格式YAML frontmattersnake_case 结构)+ markdown 正文body
API 层传输使用 camelCase dict本模块负责双向转换。
"""
from __future__ import annotations
import os
import re
import shutil
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
from config.paths import CUSTOM_SKILLS_DIR, IS_HOST_MODE
from modules.i18n import tr
# 内置示例种子目录(源码树 workflows/
BUILTIN_WORKFLOWS_DIR = Path(__file__).resolve().parent.parent / "workflows"
WORKFLOW_FILENAME = "WORKFLOW.md"
# host 模式用户库:与 CUSTOM_SKILLS_DIR 平级(<runtime_root>/<mode>/workflows
CUSTOM_WORKFLOWS_DIR = str(Path(CUSTOM_SKILLS_DIR).parent / "workflows")
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
# ---------------------------------------------------------------- 路径推断
def infer_user_workflows_dir(data_dir: str | Path | None) -> Optional[Path]:
"""从 workspace data_dir 推断用户工作流库目录(对齐 infer_private_skills_dir
host 模式:统一运行态根下的 workflows/,不按用户拆分。
web/dockerusers/<user>/personal/workflows/。
"""
if IS_HOST_MODE:
root = Path(CUSTOM_WORKFLOWS_DIR).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
if not data_dir:
return None
try:
data_path = Path(data_dir).expanduser().resolve()
if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
root = (data_path.parent.parent.parent / "workflows").resolve()
root.mkdir(parents=True, exist_ok=True)
return root
if data_path.name == "data" and data_path.parent.parent.name in ("projects", "project"):
user_root = data_path.parent.parent.parent
root = (user_root / "personal" / "workflows").resolve()
root.mkdir(parents=True, exist_ok=True)
return root
except Exception:
return None
return None
def _workflow_file(root: Path, name: str) -> Path:
return (root / name / WORKFLOW_FILENAME).resolve()
def _safe_name(name: str) -> str:
"""校验并返回合法的工作流目录名slug"""
cleaned = (name or "").strip()
if not _NAME_RE.match(cleaned):
raise ValueError(tr("workflow_manager.name_invalid", name=repr(cleaned)))
return cleaned
# ---------------------------------------------------------------- camelCase ↔ snake_case 转换
_NODE_KINDS = ("start", "end", "stage", "review", "branch")
def _node_to_yaml(node: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {"id": node.get("id"), "kind": node.get("kind")}
if node.get("name"):
out["name"] = node["name"]
kind = node.get("kind")
if kind == "stage":
out["goal"] = node.get("goal", "")
out["instructions"] = node.get("instructions", "")
out["next"] = node.get("next")
elif kind == "review":
out["prompt"] = node.get("prompt", "")
out["next"] = node.get("next")
out["reject_to"] = node.get("rejectTo")
out["max_rejects"] = node.get("maxRejects", 3)
elif kind == "branch":
out["next"] = [
{"target": r.get("target"), "condition": r.get("condition", "")}
for r in node.get("next", [])
]
elif kind == "start":
out["next"] = node.get("next")
if node.get("position"):
out["position"] = {"x": round(node["position"].get("x", 0)), "y": round(node["position"].get("y", 0))}
return out
def _node_from_yaml(data: Dict[str, Any]) -> Dict[str, Any]:
kind = data.get("kind")
node: Dict[str, Any] = {
"id": str(data.get("id") or ""),
"kind": kind if kind in _NODE_KINDS else "stage",
"name": str(data.get("name") or data.get("id") or ""),
}
if node["kind"] == "stage":
node["goal"] = str(data.get("goal") or "")
node["instructions"] = str(data.get("instructions") or "")
node["next"] = data.get("next") or None
elif node["kind"] == "review":
node["prompt"] = str(data.get("prompt") or "")
node["next"] = data.get("next") or None
node["rejectTo"] = data.get("reject_to") or None
node["maxRejects"] = int(data.get("max_rejects") or 3)
elif node["kind"] == "branch":
routes = []
for r in data.get("next") or []:
if isinstance(r, dict) and r.get("target"):
routes.append({"target": str(r["target"]), "condition": str(r.get("condition") or "")})
node["next"] = routes
elif node["kind"] == "start":
node["next"] = data.get("next") or None
pos = data.get("position")
if isinstance(pos, dict) and isinstance(pos.get("x"), (int, float)):
node["position"] = {"x": float(pos["x"]), "y": float(pos.get("y") or 0)}
return node
def workflow_to_markdown(wf: Dict[str, Any]) -> str:
"""camelCase dict → WORKFLOW.md 文本。"""
meta = {
"name": wf.get("name"),
"description": wf.get("description", ""),
"review_mode": wf.get("reviewMode", "active"),
"max_stage_rounds": int(wf.get("maxStageRounds", 20)),
"end_conditions": wf.get("endConditions", ""),
"updated_at": wf.get("updatedAt") or datetime.now().strftime("%Y-%m-%d %H:%M"),
"nodes": [_node_to_yaml(n) for n in wf.get("nodes", [])],
}
frontmatter = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
body = (wf.get("body") or "").strip()
return f"---\n{frontmatter}\n---\n\n{body}\n"
def workflow_from_markdown(text: str, source: str) -> Dict[str, Any]:
"""WORKFLOW.md 文本 → camelCase dict。"""
match = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", text, re.DOTALL)
if not match:
raise ValueError(tr("workflow_manager.markdown_missing_frontmatter"))
meta = yaml.safe_load(match.group(1)) or {}
body = match.group(2).strip()
return {
"name": str(meta.get("name") or ""),
"description": str(meta.get("description") or ""),
"reviewMode": meta.get("review_mode") if meta.get("review_mode") in ("readonly", "active") else "active",
"maxStageRounds": int(meta.get("max_stage_rounds") or 20),
"endConditions": str(meta.get("end_conditions") or ""),
"updatedAt": str(meta.get("updated_at") or ""),
"body": body,
"source": source,
"nodes": [_node_from_yaml(n) for n in meta.get("nodes") or [] if isinstance(n, dict)],
}
# ---------------------------------------------------------------- 结构校验(保存时强制 error 级)
def validate_structure(wf: Dict[str, Any]) -> List[str]:
"""返回 error 级问题列表(空 = 可保存)。规则与前端 validateWorkflow 对齐。"""
errors: List[str] = []
if not str(wf.get("name") or "").strip():
errors.append(tr("workflow_manager.wf_missing_name"))
nodes = wf.get("nodes") or []
if not nodes:
errors.append(tr("workflow_manager.min_nodes_required"))
return errors
by_id: Dict[str, Dict[str, Any]] = {}
for n in nodes:
nid = n.get("id")
if nid in by_id:
errors.append(tr("workflow_manager.duplicate_node_id", nid=nid))
by_id[nid] = n
starts = [n for n in nodes if n.get("kind") == "start"]
ends = [n for n in nodes if n.get("kind") == "end"]
if len(starts) == 0:
errors.append(tr("workflow_manager.missing_start_node"))
elif len(starts) > 1:
errors.append(tr("workflow_manager.multiple_start_nodes", count=len(starts)))
if not ends:
errors.append(tr("workflow_manager.missing_end_node"))
def check_ref(owner: Dict[str, Any], target: Any, label: str) -> None:
if not target:
errors.append(tr("workflow_manager.ref_not_connected", label=label))
elif target not in by_id:
errors.append(tr("workflow_manager.ref_target_missing", label=label, target=target))
elif by_id[target].get("kind") == "start":
errors.append(tr("workflow_manager.ref_target_is_start", label=label))
for n in nodes:
kind = n.get("kind")
name = n.get("name") or n.get("id")
if kind == "start":
check_ref(n, n.get("next"), tr("workflow_manager.label_start"))
elif kind == "stage":
check_ref(n, n.get("next"), tr("workflow_manager.label_stage", name=name))
elif kind == "review":
check_ref(n, n.get("next"), tr("workflow_manager.label_review_pass_route", name=name))
check_ref(n, n.get("rejectTo"), tr("workflow_manager.label_review_reject_route", name=name))
if not isinstance(n.get("maxRejects"), int) or n["maxRejects"] < 1:
errors.append(tr("workflow_manager.reject_limit_invalid", name=name))
elif kind == "branch":
for r in n.get("next") or []:
check_ref(n, r.get("target"), tr("workflow_manager.label_branch_route", name=name))
return errors
# ---------------------------------------------------------------- CRUD
def _iter_workflow_dirs(root: Path) -> List[Tuple[str, Path]]:
if not root.exists() or not root.is_dir():
return []
out = []
for child in sorted(root.iterdir()):
wf_file = child / WORKFLOW_FILENAME
if child.is_dir() and wf_file.exists():
out.append((child.name, wf_file))
return out
def list_workflows(data_dir: str | Path | None) -> List[Dict[str, Any]]:
"""双源合并列举(用户库同名覆盖内置)。"""
merged: Dict[str, Dict[str, Any]] = {}
for source, root in (("builtin", BUILTIN_WORKFLOWS_DIR), ("user", infer_user_workflows_dir(data_dir))):
if not root:
continue
for name, wf_file in _iter_workflow_dirs(Path(root)):
try:
wf = workflow_from_markdown(wf_file.read_text(encoding="utf-8"), source)
merged[wf["name"] or name] = {
"name": wf["name"] or name,
"description": wf["description"],
"source": source,
"updatedAt": wf["updatedAt"],
"nodeCount": len(wf["nodes"]),
}
except Exception:
# 损坏文件不拖垮列表
merged[name] = {
"name": name,
"description": tr("workflow_manager.parse_failed_description"),
"source": source,
"updatedAt": "",
"nodeCount": 0,
}
return sorted(merged.values(), key=lambda x: x["name"])
def load_workflow(name: str, data_dir: str | Path | None) -> Dict[str, Any]:
"""加载完整工作流(用户库优先,其次内置)。返回 camelCase dict。"""
_safe_name(name)
user_root = infer_user_workflows_dir(data_dir)
if user_root:
user_file = _workflow_file(user_root, name)
if user_file.exists():
return workflow_from_markdown(user_file.read_text(encoding="utf-8"), "user")
builtin_file = _workflow_file(BUILTIN_WORKFLOWS_DIR, name)
if builtin_file.exists():
return workflow_from_markdown(builtin_file.read_text(encoding="utf-8"), "builtin")
raise FileNotFoundError(tr("workflow_manager.workflow_not_found", name=name))
def save_workflow(wf: Dict[str, Any], data_dir: str | Path | None) -> Path:
"""保存到用户库(原子写)。结构 error 时 raise ValueError。"""
name = _safe_name(str(wf.get("name") or ""))
errors = validate_structure(wf)
if errors:
raise ValueError(tr("workflow_manager.structure_invalid", errors="".join(errors)))
root = infer_user_workflows_dir(data_dir)
if not root:
raise ValueError(tr("workflow_manager.cannot_infer_workflows_dir"))
wf = dict(wf)
wf["name"] = name
wf["updatedAt"] = datetime.now().strftime("%Y-%m-%d %H:%M")
target_dir = (root / name).resolve()
if not str(target_dir).startswith(str(root.resolve())):
raise ValueError(tr("workflow_manager.invalid_path"))
target_dir.mkdir(parents=True, exist_ok=True)
content = workflow_to_markdown(wf)
fd, tmp_path = tempfile.mkstemp(dir=str(target_dir), prefix=".WORKFLOW.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp_path, target_dir / WORKFLOW_FILENAME)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
return target_dir / WORKFLOW_FILENAME
def delete_workflow(name: str, data_dir: str | Path | None) -> None:
"""删除用户库中的工作流(内置示例不可删)。"""
_safe_name(name)
root = infer_user_workflows_dir(data_dir)
if not root:
raise ValueError(tr("workflow_manager.cannot_infer_workflows_dir"))
target_dir = (root / name).resolve()
if not str(target_dir).startswith(str(root.resolve())):
raise ValueError(tr("workflow_manager.invalid_path"))
if not target_dir.exists():
builtin_file = _workflow_file(BUILTIN_WORKFLOWS_DIR, name)
if builtin_file.exists():
raise ValueError(tr("workflow_manager.builtin_not_deletable"))
raise FileNotFoundError(tr("workflow_manager.workflow_not_found", name=name))
shutil.rmtree(target_dir)
def read_workflow_markdown(name: str, data_dir: str | Path | None) -> str:
"""读取工作流 WORKFLOW.md 原文(用户库优先,其次内置)。供 list_workflows 工具 name 形态。"""
_safe_name(name)
user_root = infer_user_workflows_dir(data_dir)
candidates: List[Path] = []
if user_root:
candidates.append(_workflow_file(user_root, name))
candidates.append(_workflow_file(BUILTIN_WORKFLOWS_DIR, name))
for wf_file in candidates:
if wf_file.exists():
return wf_file.read_text(encoding="utf-8")
raise FileNotFoundError(tr("workflow_manager.workflow_not_found", name=name))
def archive_workflow_directory(
source_dir: str | Path,
data_dir: str | Path | None,
*,
overwrite: bool = False,
) -> Dict[str, Any]:
"""把含有 WORKFLOW.md 的目录校验并归档到用户工作流库(对齐 archive_skill_directory
规则(与 save_workflow 工具设计定稿一致):
- 目录名必须与 frontmatter 的 name 字段一致
- overwrite=false 时:用户库已存在同名 → 报错;与内置同名 → 报错(提示将遮蔽内置)
- 覆盖前先备份旧目录,移动失败自动恢复
- 成功后源目录随 move 移除
"""
source = Path(source_dir).expanduser().resolve()
if not source.exists() or not source.is_dir():
return {"success": False, "error": tr("workflow_manager.archive_source_not_dir")}
wf_file = source / WORKFLOW_FILENAME
if not wf_file.exists() or not wf_file.is_file():
return {"success": False, "error": tr("workflow_manager.archive_missing_workflow_file", filename=WORKFLOW_FILENAME)}
try:
wf = workflow_from_markdown(wf_file.read_text(encoding="utf-8"), "user")
except Exception as exc:
return {"success": False, "error": tr("workflow_manager.archive_parse_failed", error=exc)}
raw_name = str(wf.get("name") or "").strip()
try:
name = _safe_name(raw_name)
except ValueError as exc:
return {"success": False, "error": str(exc)}
if source.name != name:
return {
"success": False,
"error": tr("workflow_manager.archive_name_mismatch", dir_name=source.name, name=name),
}
errors = validate_structure(wf)
if errors:
return {
"success": False,
"error": tr("workflow_manager.archive_structure_invalid", errors="".join(errors)),
"validation_errors": errors,
"workflow_name": name,
}
root = infer_user_workflows_dir(data_dir)
if not root:
return {"success": False, "error": tr("workflow_manager.cannot_infer_workflows_dir")}
root = root.resolve()
target = (root / name).resolve()
if not str(target).startswith(str(root)):
return {"success": False, "error": tr("workflow_manager.invalid_path")}
existed_user = target.exists()
existed_builtin = _workflow_file(BUILTIN_WORKFLOWS_DIR, name).exists()
if not overwrite:
if existed_user:
return {
"success": False,
"error": tr("workflow_manager.archive_already_exists", name=name),
"already_exists": True,
"workflow_name": name,
}
if existed_builtin:
return {
"success": False,
"error": tr("workflow_manager.archive_builtin_conflict", name=name),
"builtin_conflict": True,
"workflow_name": name,
}
backup: Optional[Path] = None
if existed_user:
backup = root / f".{name}.backup-{int(datetime.now().timestamp())}"
try:
target.rename(backup)
except Exception as exc:
return {"success": False, "error": tr("workflow_manager.archive_backup_failed", error=exc)}
try:
shutil.move(str(source), str(target))
except Exception as exc:
if backup is not None and backup.exists() and not target.exists():
try:
backup.rename(target)
except Exception:
pass
return {"success": False, "error": tr("workflow_manager.archive_move_failed", error=exc), "workflow_name": name}
if backup is not None and backup.exists():
try:
shutil.rmtree(backup)
except Exception:
pass
return {
"success": True,
"workflow_name": name,
"node_count": len(wf.get("nodes") or []),
"overwritten": existed_user,
"shadows_builtin": (not existed_user) and existed_builtin,
}