Files
MoviePilot/app/chain/workflow.py
Aqr-K 951cdc5b27 fix: arbitrate subscription identity and restore workflow state (#7147)
* fix: arbitrate subscription identity and restore workflow state

* test: preserve legacy empty-owner subscription transactions

* fix: align shared atomic configuration method with lifecycle prerequisite

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:14:15 +08:00

1548 lines
61 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.
"""
工作流的加载、调度、执行与结果通知编排。
"""
import ast
import base64
import binascii
import copy
import inspect
import pickletools
import threading
from collections import defaultdict, deque
from concurrent.futures import Future
from contextvars import Context, copy_context
from dataclasses import is_dataclass
from datetime import date, datetime
from functools import partial
from io import BytesIO, StringIO
from pickle import HIGHEST_PROTOCOL
from time import monotonic, sleep
from typing import Any, Callable, List, Optional, Tuple
from pydantic import BaseModel
from app.application.workflow import (
WorkflowExecutionPort,
WorkflowSnapshot,
get_configured_workflow_execution,
get_configured_workflow_query,
get_workflow_manager,
validate_workflow_graph,
)
from app.chain.base import ChainBase
from app.runtime.events import Event, eventmanager
from app.runtime.execution import OwnedThreadPoolExecutor
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.schemas.types import EventType
from app.schemas.workflow import Action, ActionContext, ActionExecution, ActionFlow, ActionResult
ARTIFACT_FIELDS = {"torrents", "medias", "fileitems", "downloads", "sites", "subscribes"}
DEFAULT_WORKFLOW_MAX_WORKERS = 4
WORKFLOW_EXECUTOR_STOP_TIMEOUT_SECONDS = 10.0
CIRCULAR_REFERENCE_PLACEHOLDER = "[Circular]"
def _serialize_workflow_key(key: Any) -> Any:
"""
将映射键转换为 JSON 安全值。
"""
if key is None or isinstance(key, (str, int, float, bool)):
return key
return str(key)
def _serialize_workflow_value(value: Any, stack: Optional[set[int]] = None) -> Any:
"""
把工作流上下文和值转换为可持久化的 JSON 结构。
"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (date, datetime)):
return value.isoformat()
stack = stack or set()
object_id = id(value)
if object_id in stack:
return CIRCULAR_REFERENCE_PLACEHOLDER
if isinstance(value, BaseModel):
stack.add(object_id)
try:
return {
field_name: _serialize_workflow_value(getattr(value, field_name, None), stack)
for field_name in value.__class__.model_fields
}
finally:
stack.remove(object_id)
to_dict = getattr(value, "to_dict", None) if is_dataclass(value) and not isinstance(value, type) else None
if callable(to_dict):
# RSS 等动作按设计把领域 dataclass(如 app.domain.context.Context)放进上下文,其 to_dict
# 与工作流 Schema 字段对齐,恢复时可经 ActionContext 校验还原;落到 str() 会存成无法恢复的文本。
stack.add(object_id)
try:
return _serialize_workflow_value(to_dict(), stack)
finally:
stack.remove(object_id)
if isinstance(value, dict):
stack.add(object_id)
try:
return {
_serialize_workflow_key(key): _serialize_workflow_value(item, stack)
for key, item in value.items()
}
finally:
stack.remove(object_id)
if isinstance(value, (list, tuple, set)):
stack.add(object_id)
try:
return [_serialize_workflow_value(item, stack) for item in value]
finally:
stack.remove(object_id)
return str(value)
def _serialize_workflow_context(context: ActionContext) -> dict[str, Any]:
"""
构建可写入数据库的工作流上下文字典。
"""
serialized = _serialize_workflow_value(context)
return serialized if isinstance(serialized, dict) else {}
class WorkflowCancelToken:
"""
工作流取消令牌。
"""
def __init__(
self,
workflow_id: int,
stop_event: Optional[threading.Event] = None,
) -> None:
"""
初始化取消令牌。
:param workflow_id: 工作流ID
:param stop_event: 单次执行 任务持有者 的本地停止信号
"""
self.workflow_id = workflow_id
self.stop_event = stop_event
def is_cancelled(self) -> bool:
"""
判断工作流是否已被取消。
"""
return bool(
(self.stop_event and self.stop_event.is_set())
or runtime_stop_state.is_workflow_stopped(self.workflow_id)
)
class WorkflowExecutor:
"""
工作流执行器
"""
def __init__(
self,
workflow: WorkflowSnapshot,
step_callback: Optional[Callable[..., object]] = None,
*,
from_begin: bool = False,
completion_callback: Optional[Callable[["WorkflowExecutor"], None]] = None,
):
"""
初始化工作流执行器
:param workflow: 工作流对象
:param step_callback: 步骤回调函数
:param from_begin: 忽略历史节点和上下文,从头构建本次执行
:param completion_callback: 节点真实终止后、释放同 ID 所有权前提交终态
"""
validate_workflow_graph(workflow.actions, workflow.flows)
self.workflow = workflow
self._from_begin = from_begin
self._completion_callback = completion_callback
self.step_callback = step_callback
self.step_callback_extended = (
len(inspect.signature(step_callback).parameters) > 2
if step_callback
else False
)
self.actions: dict[str, Action] = {}
for action_data in workflow.actions or ():
action = Action.model_validate(dict(action_data))
if not action.id:
raise ValueError("工作流动作缺少 ID")
self.actions[action.id] = action
self.flows = [ActionFlow.model_validate(dict(flow)) for flow in workflow.flows or ()]
execution_config = getattr(workflow, "execution_config", None) or {}
execution_state = {} if from_begin else (getattr(workflow, "execution_state", None) or {})
self.execution_config = (
execution_config.model_dump(exclude_none=True)
if isinstance(execution_config, BaseModel)
else execution_config
)
self.restored_execution_state = (
execution_state.model_dump(exclude_none=True)
if isinstance(execution_state, BaseModel)
else execution_state
)
self.total_actions = len(self.actions)
self.success = True
self.has_failure = False
self.stopped = False
self.errmsg = ""
self.errors = self.get_restored_errors()
self.node_metadata = self.get_restored_node_metadata()
self.node_attempts = self.get_restored_attempts()
self.node_states = self.get_restored_node_states()
self.completed_actions = {
action_id for action_id, state in self.node_states.items()
if state == "success"
}
self.finished_actions = len([
state for state in self.node_states.values()
if state in ("success", "failed", "skipped")
])
self.flow_finished: set[str] = set()
self.flow_satisfied: set[str] = set()
self.flow_failed: set[str] = set()
self.workflowmanager = get_workflow_manager()
# 具体管理器登记活动执行;旧自定义 能力提供方 不实现 任务持有者 接口时保持原调用兼容。
self._execution_lock = threading.RLock()
self._admission_state = "pending"
self._registered_execution = False
self._stop_event = threading.Event()
self._execute_returned = threading.Event()
self._stopped_event = threading.Event()
self.queue: deque[str] = deque()
self.queued_actions: set[str] = set()
self.active_concurrency_keys: set[str] = set()
self.lock = threading.Lock()
self.executor = OwnedThreadPoolExecutor(max_workers=self.get_workflow_max_workers())
self.cancel_token = WorkflowCancelToken(self.workflow.id, self._stop_event)
self.running_tasks = 0
# 构建出边与入边表,用于条件流转和多上游汇合。
self.outgoing_flows = defaultdict(list)
self.incoming_flows = defaultdict(list)
for flow in self.flows:
if not flow.source or not flow.target:
continue
self.outgoing_flows[flow.source].append(flow)
self.incoming_flows[flow.target].append(flow)
self.context = self.restore_context()
self.ensure_context_partitions()
# 恢复时重新释放已终态节点的出边,使后继节点能继续执行或保持跳过传播。
for action_id, state in self.node_states.items():
if state == "success":
self.release_successors(action_id, source_success=True)
elif state in ("failed", "skipped"):
self.release_successors(action_id, source_success=False)
# 初始化队列,添加没有入边的起始节点。
for action_id in self.actions:
if self.node_states.get(action_id) == "pending" and not self.incoming_flows.get(action_id):
self.enqueue_node(action_id)
def admit(self) -> bool:
"""
向 concrete manager 登记本次执行,并保持旧 能力提供方 可直接运行。
"""
with self._execution_lock:
if self._admission_state == "admitted":
return True
if self._admission_state == "rejected":
return False
register = getattr(self.workflowmanager, "register_execution", None)
if callable(register) and not register(self):
self._admission_state = "rejected"
self.success = False
self.stopped = True
self.errmsg = "工作流正在执行或服务正在停止"
self.executor.shutdown_bounded(timeout=0.0)
self._execute_returned.set()
self._stopped_event.set()
return False
self._registered_execution = callable(register)
self._admission_state = "admitted"
# 只有获得执行准入后才能清除历史单工作流停止标记。
runtime_stop_state.resume_workflow(self.workflow.id)
return True
@property
def workflow_id(self) -> int:
"""
返回执行 任务持有者 互斥准入所使用的工作流 ID。
"""
return self.workflow.id
def request_stop(self) -> None:
"""
停止调度新节点,并通过本地令牌通知支持取消的活动动作。
"""
self._stop_event.set()
def abort_before_execute(self) -> None:
"""
执行状态启动失败时释放尚未使用的节点池和 manager 任务持有者。
"""
self.request_stop()
# reset/start 失败尚未真正执行,不提交误导性的成功或失败终态。
self._completion_callback = None
converged = self.executor.shutdown_bounded(timeout=0.0)
self._execute_returned.set()
if converged:
self._release_execution()
def wait_stopped(self, timeout: float) -> bool:
"""
有限等待 execute 返回,并在需要时重试节点线程池收敛。
"""
deadline = monotonic() + max(0.0, timeout)
if self._stopped_event.is_set():
return True
if not self._execute_returned.wait(
timeout=max(0.0, deadline - monotonic()),
):
return False
if self._stopped_event.is_set():
return True
if not self.executor.shutdown_bounded(
timeout=max(0.0, deadline - monotonic()),
):
return False
self._release_execution()
return self._stopped_event.is_set()
def _release_execution(self) -> None:
"""
先完成终态事务,再释放已真实终止的执行 任务持有者;事务失败允许重试。
"""
with self._execution_lock:
if self._stopped_event.is_set():
return
if self._completion_callback is not None:
self._completion_callback(self)
self._completion_callback = None
if self._registered_execution:
unregister = getattr(self.workflowmanager, "unregister_execution", None)
if callable(unregister):
unregister(self)
self._registered_execution = False
self._stopped_event.set()
def _stop_requested(self) -> bool:
"""
判断本次执行或全局工作流是否已收到停止请求。
"""
return bool(
self._stop_event.is_set()
or runtime_stop_state.is_workflow_stopped(self.workflow.id)
)
def get_workflow_max_workers(self) -> int:
"""
获取工作流最大并发数。
"""
max_workers = self.execution_config.get("max_workers") if isinstance(self.execution_config, dict) else None
try:
return max(int(max_workers or DEFAULT_WORKFLOW_MAX_WORKERS), 1)
except (TypeError, ValueError):
return DEFAULT_WORKFLOW_MAX_WORKERS
def get_restored_node_metadata(self) -> dict[str, Any]:
"""
获取已持久化的节点状态元数据。
"""
nodes = self.restored_execution_state.get("nodes") if isinstance(self.restored_execution_state, dict) else {}
return nodes if isinstance(nodes, dict) else {}
def get_restored_errors(self) -> dict[str, Any]:
"""
获取已持久化的错误状态。
"""
errors = self.restored_execution_state.get("errors") if isinstance(self.restored_execution_state, dict) else {}
return errors if isinstance(errors, dict) else {}
def get_restored_attempts(self) -> dict[str, Any]:
"""
获取已持久化的节点尝试次数。
"""
attempts = {}
for action_id, metadata in self.get_restored_node_metadata().items():
if isinstance(metadata, dict) and metadata.get("attempt"):
attempts[action_id] = int(metadata.get("attempt") or 0)
return attempts
def get_restored_node_states(self) -> dict[str, Any]:
"""
获取结构化节点状态,兼容旧版 current_action 字符串。
"""
legacy_actions = {
action_id for action_id in (self.workflow.current_action or "").split(",")
if not self._from_begin and action_id in self.actions
}
states = {}
for action_id in self.actions:
metadata = self.node_metadata.get(action_id) or {}
state = metadata.get("state") if isinstance(metadata, dict) else None
if state == "completed":
state = "success"
if state in ("running", "queued", "failed", "skipped"):
state = "pending"
if not state and action_id in legacy_actions:
state = "success"
states[action_id] = state or "pending"
return states
def restore_context(self) -> ActionContext:
"""
恢复工作流上下文。
只接受结构化 JSON 上下文;旧版 Base64 Pickle 格式(``content`` 字段)不再反序列化,
按空上下文从头执行,避免执行数据库中来源不可信的序列化载荷。
"""
context = ActionContext()
if self._from_begin:
return context
if self.workflow.context:
if self.workflow.current_action:
logger.info(f"工作流已执行动作:{self.workflow.current_action}")
try:
if self.is_legacy_context(self.workflow.context):
logger.warning(f"工作流 {self.workflow.name} 的上下文为已废弃的旧版格式,将从空上下文开始执行")
elif isinstance(self.workflow.context, dict):
context = ActionContext.model_validate(self.workflow.context)
except Exception:
context = ActionContext()
outputs = self.restored_execution_state.get("outputs") if isinstance(self.restored_execution_state, dict) else {}
if outputs and not getattr(context, "node_outputs", None):
context.node_outputs = outputs
return context
@staticmethod
def is_legacy_context(context: Any) -> bool:
"""
只识别单字段的旧 Base64 Pickle 包装,绝不反序列化载荷。
当前文本 content 即使是唯一字段也属于合法结构,不能据其非空丢弃上下文。
:param context: 持久化的工作流上下文字典
:return: 仅旧包装内含支持协议的完整指令流、栈合法且无尾部数据时为 True
"""
if not isinstance(context, dict) or set(context) != {"content"}:
return False
content = context.get("content")
if not isinstance(content, str):
return False
try:
payload = base64.b64decode(content, validate=True)
if len(payload) < 2 or payload[0] != 0x80 or not 2 <= payload[1] <= HIGHEST_PROTOCOL:
return False
stream = BytesIO(payload)
# 静态解析校验指令、memo 与栈,不导入载荷引用,也不执行其 callable。
pickletools.dis(stream, out=StringIO())
return stream.tell() == len(payload)
except (ValueError, binascii.Error):
return False
def ensure_context_partitions(self) -> None:
"""
确保上下文具备新版分区结构,并把旧字段映射到 artifacts。
"""
self.context.workflow_context = self.context.workflow_context or {}
self.context.node_outputs = self.context.node_outputs or {}
self.context.runtime_state = self.context.runtime_state or {}
self.context.artifacts = self.context.artifacts or {}
for key in ARTIFACT_FIELDS:
value = getattr(self.context, key, None)
if value not in (None, "", [], {}) and key not in self.context.artifacts:
self.context.artifacts[key] = value
self.update_runtime_state()
def update_runtime_state(self) -> None:
"""
更新上下文中的运行期状态分区。
"""
if self.context.runtime_state is None:
self.context.runtime_state = {}
self.context.runtime_state.update({
"progress": self.context.progress,
"finished_actions": self.finished_actions,
"running_tasks": self.running_tasks,
"errors": self.errors,
"node_states": self.node_states,
"attempts": self.node_attempts,
})
def set_node_state(self, action_id: str, state: str, message: Optional[str] = None) -> None:
"""
更新节点结构化状态。
"""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
metadata = self.node_metadata.setdefault(action_id, {})
metadata["state"] = state
if state == "success":
self.errors.pop(action_id, None)
metadata["attempt"] = self.node_attempts.get(action_id, metadata.get("attempt") or 0)
if state == "running":
metadata["started_at"] = now
if state in ("success", "failed", "skipped"):
metadata["finished_at"] = now
if message is not None:
metadata["message"] = message
self.node_states[action_id] = state
self.update_runtime_state()
def build_execution_state(self) -> dict[str, Any]:
"""
构建可持久化的结构化执行状态。
"""
self.update_runtime_state()
execution_state = _serialize_workflow_value({
"version": 1,
"nodes": self.node_metadata,
"outputs": self.context.node_outputs,
"errors": self.errors,
"runtime": self.context.runtime_state,
})
return execution_state if isinstance(execution_state, dict) else {}
def execute(self) -> None:
"""
执行工作流
"""
if not self.admit():
return
try:
while True:
should_sleep = False
node_id = None
with self.lock:
if self._stop_requested():
self.success = False
self.stopped = True
self.errmsg = "工作流已停止"
if self.running_tasks == 0:
break
should_sleep = True
# 退出条件:队列为空且无运行任务
elif not self.queue and self.running_tasks == 0:
break
# 出错后不再调度新节点,但等待已提交节点完成,避免后台线程继续写状态。
if not self.success:
if self.running_tasks == 0:
break
should_sleep = True
elif not self.queue:
should_sleep = True
else:
node_id = self.pop_dispatchable_node()
if not node_id:
should_sleep = True
if should_sleep:
sleep(0.1)
continue
if not node_id:
continue
# 节点分别复制业务上下文和调用上下文,避免共享可变状态或丢失触发链路。
context = copy_context()
future = Context().run(
self.executor.submit,
context.run,
self.execute_node,
self.workflow.id,
node_id,
copy.deepcopy(self.context),
)
future.add_done_callback(partial(self._complete_in_context, context))
except Exception as err:
self.success = False
self.has_failure = True
self.errmsg = str(err)
raise
finally:
converged = self.executor.shutdown_bounded(
timeout=WORKFLOW_EXECUTOR_STOP_TIMEOUT_SECONDS,
)
try:
if converged:
self._release_execution()
finally:
# 终态事务仍在进行时,停机等待不能把节点线程结束等同于 任务持有者 已释放。
self._execute_returned.set()
def pop_dispatchable_node(self) -> Optional[str]:
"""
从队列中取出当前可调度节点。
"""
for _ in range(len(self.queue)):
node_id = self.queue.popleft()
self.queued_actions.discard(node_id)
if self.node_states.get(node_id) != "queued":
continue
concurrency_key = self.get_action_concurrency_key(self.actions[node_id])
if concurrency_key and concurrency_key in self.active_concurrency_keys:
self.queue.append(node_id)
self.queued_actions.add(node_id)
continue
if concurrency_key:
self.active_concurrency_keys.add(concurrency_key)
self.running_tasks += 1
self.set_node_state(node_id, "running")
return node_id
return None
def execute_node(self, workflow_id: int, node_id: str,
context: ActionContext) -> Tuple[Action, ActionResult]:
"""
执行单个节点操作,返回修改后的上下文和节点ID
"""
action = self.actions[node_id]
action_result = self.workflowmanager.execute(
workflow_id,
action,
context=context,
inputs=self.build_action_inputs(action),
runtime=self.build_action_runtime(action),
cancel_token=self.cancel_token
)
return action, action_result
def _complete_in_context(
self,
context: Context,
future: Future[Tuple[Action, ActionResult]],
) -> None:
"""
在节点提交时的上下文中消费完成结果。
"""
context.run(self.on_node_complete, future)
def on_node_complete(self, future: Future[Tuple[Action, ActionResult]]) -> None:
"""
节点完成回调:更新上下文、处理后继节点
"""
action = None
try:
action, action_result = future.result()
if not action.id:
raise ValueError("工作流动作缺少 ID")
with self.lock:
if self._stop_requested():
self.success = False
self.stopped = True
self.errmsg = "工作流已停止"
return
state = bool(action_result.success)
message = action_result.message or ""
result_ctx = action_result.context or ActionContext()
self.node_attempts[action.id] = action_result.attempts or self.node_attempts.get(action.id, 1)
self.finished_actions += 1
self.update_progress()
if self.context.execute_history is None:
self.context.execute_history = []
self.context.execute_history.append(
ActionExecution(
action=action.name,
result=state,
message=message
)
)
# 节点执行失败时默认停止;显式配置 continue/ignore 时继续释放后续 all_done 汇合。
if not state:
self.errors[action.id] = message or f"{action.name} 失败"
self.set_node_state(action.id, "failed", message=message)
fail_policy = self.get_action_fail_policy(action)
if fail_policy != "ignore":
self.has_failure = True
self.errmsg = f"{action.name} 失败"
if fail_policy == "stop":
self.success = False
self.call_step_callback(action, completed=False)
return
if fail_policy not in ("continue", "ignore"):
self.success = False
self.errmsg = f"{action.name} 失败:无效失败策略 {fail_policy}"
self.call_step_callback(action, completed=False)
return
self.release_successors(action.id, source_success=False)
self.call_step_callback(action, completed=False)
return
self.ensure_result_context_partitions(result_ctx)
outputs = self.normalize_action_outputs(action, action_result, result_ctx)
self.merge_context_partitions(result_ctx)
self.merge_action_outputs(action, outputs)
self.record_node_outputs(action.id, outputs)
self.completed_actions.add(action.id)
self.set_node_state(action.id, "success", message=message)
self.release_successors(action.id, source_success=True)
self.call_step_callback(action, completed=True)
except Exception as err:
logger.error(f"工作流节点执行回调失败: {str(err)}")
with self.lock:
self.success = False
self.errmsg = str(err)
finally:
with self.lock:
if action:
concurrency_key = self.get_action_concurrency_key(action)
if concurrency_key:
self.active_concurrency_keys.discard(concurrency_key)
self.running_tasks -= 1
self.update_runtime_state()
def enqueue_node(self, node_id: str) -> None:
"""
将满足条件的节点加入待执行队列。
"""
if node_id not in self.actions:
return
if self.node_states.get(node_id) != "pending" or node_id in self.queued_actions:
return
self.queue.append(node_id)
self.queued_actions.add(node_id)
self.set_node_state(node_id, "queued")
def skip_node(self, node_id: str, message: str) -> None:
"""
将不可达节点标记为跳过,并把跳过状态继续传递给后继节点。
"""
if node_id not in self.actions:
return
if self.node_states.get(node_id) not in ("pending", "queued"):
return
self.queued_actions.discard(node_id)
self.finished_actions += 1
self.update_progress()
self.set_node_state(node_id, "skipped", message=message)
if self.context.execute_history is None:
self.context.execute_history = []
self.context.execute_history.append(
ActionExecution(
action=self.actions[node_id].name,
result=True,
message=message
)
)
self.call_step_callback(self.actions[node_id], completed=False)
self.release_successors(node_id, source_success=False)
def release_successors(self, source_id: str, source_success: bool) -> None:
"""
根据源节点状态释放出边,并重新判断目标节点是否可运行。
"""
flows = self.outgoing_flows.get(source_id, [])
branch_policy = self.get_action_branch_policy(self.actions.get(source_id), flows)
matched_exclusive_flow = None
for flow in flows:
flow_key = self.get_flow_key(flow)
if flow_key in self.flow_finished:
continue
condition_matched = False
if source_success:
try:
condition_matched = self.evaluate_condition(self.get_flow_condition(flow))
except ValueError as err:
self.success = False
self.errmsg = f"流程条件判断失败:{err}"
return
if branch_policy == "exclusive" and condition_matched and matched_exclusive_flow:
condition_matched = False
elif branch_policy == "exclusive" and condition_matched:
matched_exclusive_flow = flow_key
self.flow_finished.add(flow_key)
if source_success and condition_matched:
self.flow_satisfied.add(flow_key)
if not source_success and self.node_states.get(source_id) == "failed":
self.flow_failed.add(flow_key)
if flow.target:
self.evaluate_target_state(flow.target)
def evaluate_target_state(self, target_id: str) -> None:
"""
按目标节点汇合策略判断节点是否入队或跳过。
"""
if not target_id or target_id not in self.actions:
return
if self.node_states.get(target_id) != "pending":
return
incoming_flows = self.incoming_flows.get(target_id, [])
if not incoming_flows:
self.enqueue_node(target_id)
return
total_count = len(incoming_flows)
finished_count = sum(1 for flow in incoming_flows if self.get_flow_key(flow) in self.flow_finished)
satisfied_count = sum(1 for flow in incoming_flows if self.get_flow_key(flow) in self.flow_satisfied)
failed_count = sum(1 for flow in incoming_flows if self.get_flow_key(flow) in self.flow_failed)
join_policy = self.get_action_join_policy(self.actions[target_id], incoming_flows)
if join_policy == "fail_fast":
if failed_count > 0:
self.skip_node(target_id, "上游失败触发 fail_fast,已取消后续节点")
elif finished_count == total_count and satisfied_count == total_count:
self.enqueue_node(target_id)
elif finished_count == total_count:
self.skip_node(target_id, "上游条件未全部满足,已跳过")
return
if join_policy == "any_success":
if satisfied_count > 0:
self.enqueue_node(target_id)
elif finished_count == total_count:
self.skip_node(target_id, "所有上游条件均未满足,已跳过")
return
if join_policy == "all_done":
if finished_count == total_count:
self.enqueue_node(target_id)
return
if join_policy != "all_success":
self.success = False
self.errmsg = f"{self.actions[target_id].name} 汇合策略无效:{join_policy}"
return
if finished_count != total_count:
return
if satisfied_count == total_count:
self.enqueue_node(target_id)
else:
self.skip_node(target_id, "上游条件未全部满足,已跳过")
def update_progress(self) -> None:
"""
根据已完成和已跳过节点数量更新整体进度。
"""
self.context.progress = round(self.finished_actions / self.total_actions * 100) if self.total_actions else 100
self.update_runtime_state()
def build_action_inputs(self, action: Action) -> dict[str, Any]:
"""
根据动作输入声明读取上游节点输出。
"""
inputs = {}
input_paths = action.inputs or self.get_action_data_value(action, "inputs")
if not input_paths:
input_paths = [
field["name"] for field in self.get_action_contract(action).get("inputs") or []
]
if isinstance(input_paths, str):
input_paths = [item.strip() for item in input_paths.splitlines() if item.strip()]
for input_path in input_paths:
inputs[input_path] = self.resolve_action_input(input_path)
return inputs
def resolve_action_input(self, input_path: str) -> Any:
"""
解析动作输入声明。
"""
if input_path in ActionContext.model_fields:
value = getattr(self.context, input_path, None)
if value not in (None, "", [], {}):
return value
return self.context.artifacts.get(input_path) if self.context.artifacts else value
return self.resolve_context_path(input_path)
def build_action_runtime(self, action: Action) -> dict[str, Any]:
"""
构建传递给动作的新运行期数据。
"""
return {
"workflow_id": self.workflow.id,
"action_id": action.id,
"execution_config": self.execution_config,
"runtime_state": self.context.runtime_state,
}
@staticmethod
def ensure_result_context_partitions(context: ActionContext) -> None:
"""
确保动作返回上下文具备新版分区字段。
"""
context.workflow_context = context.workflow_context or {}
context.node_outputs = context.node_outputs or {}
context.runtime_state = context.runtime_state or {}
context.artifacts = context.artifacts or {}
def normalize_action_outputs(self, action: Action, action_result: ActionResult,
result_context: ActionContext) -> dict[str, Any]:
"""
根据动作输出声明整理当前节点输出。
"""
outputs = action_result.outputs or self.extract_context_outputs(result_context)
declared_outputs = self.get_action_output_declarations(action)
if isinstance(declared_outputs, list):
normalized_outputs = {}
missing = object()
for item in declared_outputs:
key = item.get("name") if isinstance(item, dict) else item
if not key:
continue
value = outputs.get(key, missing)
if value is missing and key in result_context.__class__.model_fields:
value = getattr(result_context, key, missing)
if value is not missing and self.should_keep_output_value(action, key, value):
normalized_outputs[key] = value
return normalized_outputs or outputs
if isinstance(declared_outputs, dict):
normalized_outputs = {}
missing = object()
for key in declared_outputs:
value = outputs.get(key, missing)
if value is missing and key in result_context.__class__.model_fields:
value = getattr(result_context, key, missing)
if value is not missing and self.should_keep_output_value(action, key, value):
normalized_outputs[key] = value
return normalized_outputs or outputs
return outputs
def should_keep_output_value(self, action: Action, key: str, value: Any) -> bool:
"""
判断输出值是否应参与后续合并。
"""
if value not in (None, "", [], {}):
return True
output_config = self.get_action_output_config(action, key)
target_key = output_config.get("target") or key
merge_policy = output_config.get("merge") or self.get_default_merge_policy(action, target_key, value)
return merge_policy == "replace"
def record_node_outputs(self, action_id: str, outputs: dict[str, Any]) -> None:
"""
记录当前节点输出,供后续条件表达式读取。
"""
if outputs:
if self.context.node_outputs is None:
self.context.node_outputs = {}
if self.context.runtime_state is None:
self.context.runtime_state = {}
self.context.node_outputs[action_id] = outputs
self.context.runtime_state["last_outputs"] = outputs
def merge_context_partitions(self, context: ActionContext) -> None:
"""
合并动作返回的新分区上下文。
"""
for key in ("workflow_context", "runtime_state", "artifacts"):
value = getattr(context, key, None)
if not value:
continue
current_value = getattr(self.context, key, None) or {}
current_value.update(value)
setattr(self.context, key, current_value)
def merge_action_outputs(self, action: Action, outputs: dict[str, Any]) -> None:
"""
按声明式合并策略写入全局上下文和 artifacts 分区。
"""
for key, value in outputs.items():
output_config = self.get_action_output_config(action, key)
target_key = output_config.get("target") or key
merge_policy = output_config.get("merge") or self.get_default_merge_policy(action, target_key, value)
if value in (None, "", [], {}) and merge_policy != "replace":
continue
identity = output_config.get("identity")
self.merge_output_value(target_key, value, merge_policy, identity)
def merge_output_value(self, key: str, value: Any, merge_policy: str, identity: Optional[str] = None) -> None:
"""
按指定策略合并单个输出值。
"""
current_value = getattr(self.context, key, None) if key in ActionContext.model_fields else None
merged_value = self.apply_merge_policy(current_value, value, merge_policy, identity)
if key in ActionContext.model_fields:
setattr(self.context, key, merged_value)
if key in ARTIFACT_FIELDS:
if self.context.artifacts is None:
self.context.artifacts = {}
current_artifact = self.context.artifacts.get(key)
self.context.artifacts[key] = self.apply_merge_policy(current_artifact, value, merge_policy, identity)
def get_action_output_config(self, action: Action, output_key: str) -> dict[str, Any]:
"""
获取动作输出声明配置。
"""
outputs_config = self.get_action_output_declarations(action)
if isinstance(outputs_config, dict):
value = outputs_config.get(output_key) or {}
return value if isinstance(value, dict) else {}
if isinstance(outputs_config, list):
for item in outputs_config:
if isinstance(item, dict) and item.get("name") == output_key:
return {
key: value for key, value in item.items()
if key not in ("name", "label", "kind") and value not in (None, "", [], {})
}
return {}
def get_action_output_declarations(self, action: Action) -> Any:
"""
获取动作输出声明,优先使用节点显式配置,其次使用动作固定契约。
"""
outputs_config = action.outputs or self.get_action_data_value(action, "outputs")
if outputs_config:
return outputs_config
return self.get_action_contract(action).get("outputs") or {}
@staticmethod
def get_default_merge_policy(action: Action, key: str, value: Any) -> str:
"""
获取输出默认合并策略。
"""
if isinstance(value, list):
action_type = action.type or ""
action_name = action.name or ""
if key in ARTIFACT_FIELDS and (
action_type.startswith("Filter")
or action_name.startswith("过滤")
):
return "replace"
return "append_unique"
if isinstance(value, dict):
return "merge_dict"
return "first_non_empty"
def apply_merge_policy(self, current_value: Any, value: Any, merge_policy: str,
identity: Optional[str] = None) -> Any:
"""
应用声明式合并策略。
"""
if merge_policy == "replace":
return value
if merge_policy == "merge_dict":
merged = current_value.copy() if isinstance(current_value, dict) else {}
if isinstance(value, dict):
merged.update(value)
return merged
return current_value or value
if merge_policy == "append_unique":
return self.append_unique_values(current_value, value, identity)
if merge_policy == "first_non_empty":
return current_value or value
return current_value or value
def append_unique_values(self, current_value: Any, value: Any, identity: Optional[str] = None) -> list[Any]:
"""
追加列表并按身份字段去重。
"""
current_list = list(current_value or [])
incoming_list = value if isinstance(value, list) else [value]
seen = {self.get_identity_value(item, identity) for item in current_list}
for item in incoming_list:
identity_value = self.get_identity_value(item, identity)
if identity_value in seen:
continue
current_list.append(item)
seen.add(identity_value)
return current_list
def get_identity_value(self, item: Any, identity: Optional[str] = None) -> Any:
"""
获取列表元素去重身份。
"""
if not identity:
return self.make_hashable_identity(item)
value = item
for part in identity.split("."):
value = self.read_value(value, int(part) if part.isdigit() else part)
return self.make_hashable_identity(value)
@staticmethod
def make_hashable_identity(value: Any) -> Any:
"""
将身份值转换为可哈希对象。
"""
try:
hash(value)
return value
except TypeError:
return repr(value)
def call_step_callback(self, action: Action, completed: bool) -> None:
"""
持久化当前步骤上下文和结构化执行状态。
"""
if not self.step_callback:
return
if self.step_callback_extended:
self.step_callback(action, self.context, self.build_execution_state(), completed)
return
self.step_callback(action, self.context)
@staticmethod
def extract_context_outputs(context: ActionContext) -> dict[str, Any]:
"""
从动作上下文中提取非空业务字段作为节点默认输出。
"""
if not context:
return {}
outputs = {}
for key in context.__class__.model_fields:
if key in ("execute_history", "progress", "node_outputs", "runtime_state"):
continue
value = getattr(context, key, None)
if value in (None, "", [], {}):
continue
outputs[key] = value
return outputs
@staticmethod
def get_flow_key(flow: ActionFlow) -> str:
"""
生成流程边的运行期唯一标识。
"""
return flow.id or f"{flow.source}->{flow.target}:{id(flow)}"
def get_action_join_policy(self, action: Action, incoming_flows: List[ActionFlow]) -> str:
"""
获取动作汇合策略,优先使用动作配置,其次兼容流程边配置。
"""
join_policy = action.join_policy or self.get_action_data_value(action, "join_policy")
if join_policy:
return join_policy
for flow in incoming_flows:
join_policy = flow.join_policy or self.get_flow_data_value(flow, "join_policy")
if join_policy:
return join_policy
return "all_success"
def get_action_branch_policy(self, action: Optional[Action], outgoing_flows: List[ActionFlow]) -> str:
"""
获取动作出边分支策略。
"""
if action:
branch_policy = action.branch_policy or self.get_action_data_value(action, "branch_policy")
if branch_policy:
return branch_policy
for flow in outgoing_flows:
branch_policy = flow.branch_policy or self.get_flow_data_value(flow, "branch_policy")
if branch_policy:
return branch_policy
return "parallel"
def get_action_fail_policy(self, action: Action) -> str:
"""
获取动作失败策略。
"""
return action.fail_policy or self.get_action_data_value(action, "fail_policy") or "stop"
def get_action_concurrency_key(self, action: Action) -> Optional[str]:
"""
获取动作并发互斥键。
"""
return (
action.concurrency_key
or self.get_action_data_value(action, "concurrency_key")
or self.get_action_contract(action).get("concurrency_key")
)
def get_action_contract(self, action: Action) -> dict[str, Any]:
"""
获取动作固定输入输出契约。
"""
get_contract = getattr(self.workflowmanager, "get_action_contract", None)
if not get_contract:
return {}
return get_contract(action.type) or {}
def get_flow_condition(self, flow: ActionFlow) -> Optional[str]:
"""
获取流程边条件表达式。
"""
return flow.condition or self.get_flow_data_value(flow, "condition")
@staticmethod
def get_action_data_value(action: Optional[Action], key: str) -> Any:
"""
从动作 data 中读取扩展配置。
"""
if not action:
return None
data = action.data or {}
return data.get(key) if isinstance(data, dict) else None
@staticmethod
def get_flow_data_value(flow: ActionFlow, key: str) -> Any:
"""
从流程边 data 中读取扩展配置。
"""
data = flow.data or {}
return data.get(key) if isinstance(data, dict) else None
def evaluate_condition(self, condition: Optional[str]) -> bool:
"""
安全计算流程边条件表达式。
"""
if not condition:
return True
expression = condition.strip()
if not expression:
return True
expression = expression.replace("&&", " and ").replace("||", " or ")
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as err:
raise ValueError(f"{condition} 语法错误") from err
return bool(self.evaluate_condition_node(tree.body))
def evaluate_condition_node(self, node: ast.AST) -> Any:
"""
递归计算受限 AST 节点,避免执行任意代码。
"""
if isinstance(node, ast.BoolOp):
values = [bool(self.evaluate_condition_node(value)) for value in node.values]
if isinstance(node.op, ast.And):
return all(values)
if isinstance(node.op, ast.Or):
return any(values)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
return not bool(self.evaluate_condition_node(node.operand))
if isinstance(node, ast.Compare):
return self.evaluate_compare_node(node)
if isinstance(node, ast.Name):
return self.resolve_condition_name(node.id)
if isinstance(node, ast.Attribute):
return self.read_value(self.evaluate_condition_node(node.value), node.attr)
if isinstance(node, ast.Subscript):
return self.read_subscript_node(node)
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.List):
return [self.evaluate_condition_node(item) for item in node.elts]
if isinstance(node, ast.Tuple):
return tuple(self.evaluate_condition_node(item) for item in node.elts)
if isinstance(node, ast.Set):
return {self.evaluate_condition_node(item) for item in node.elts}
if isinstance(node, ast.Dict):
if any(key is None for key in node.keys):
raise ValueError("条件表达式不支持字典展开")
return {
self.evaluate_condition_node(key): self.evaluate_condition_node(value)
for key, value in zip(node.keys, node.values)
if key is not None
}
raise ValueError(f"不支持的条件表达式:{ast.dump(node)}")
def evaluate_compare_node(self, node: ast.Compare) -> bool:
"""
计算比较表达式,支持链式比较和成员判断。
"""
left = self.evaluate_condition_node(node.left)
for operator, comparator in zip(node.ops, node.comparators):
right = self.evaluate_condition_node(comparator)
if not self.compare_values(left, operator, right):
return False
left = right
return True
def read_subscript_node(self, node: ast.Subscript) -> Any:
"""
读取下标访问表达式。
"""
if isinstance(node.slice, ast.Slice):
raise ValueError("条件表达式不支持切片访问")
container = self.evaluate_condition_node(node.value)
key = self.evaluate_condition_node(node.slice)
return self.read_value(container, key)
def resolve_condition_name(self, name: str) -> Any:
"""
将条件表达式中的根名称映射到当前工作流上下文。
"""
if name in ("true", "True"):
return True
if name in ("false", "False"):
return False
if name in ("none", "None", "null"):
return None
if name == "context":
return self.context
if name == "workflow_context":
return self.context.workflow_context or {}
if name == "runtime_state":
return self.context.runtime_state or {}
if name == "artifacts":
return self.context.artifacts or {}
if name in ("outputs", "node_outputs"):
return self.context.node_outputs or {}
if name == "last":
return self.context.runtime_state.get("last_outputs") if self.context.runtime_state else {}
node_outputs = self.context.node_outputs or {}
if name in node_outputs:
return node_outputs[name]
if name in ActionContext.model_fields:
return getattr(self.context, name, None)
raise ValueError(f"未知上下文变量 {name}")
def resolve_context_path(self, path: str) -> Any:
"""
按点分路径读取工作流上下文数据。
"""
if not path:
return None
value = None
for index, part in enumerate(path.split(".")):
if index == 0:
value = self.resolve_condition_name(part)
continue
key = int(part) if part.isdigit() else part
value = self.read_value(value, key)
return value
@staticmethod
def read_value(value: Any, key: Any) -> Any:
"""
从 dict、对象或序列中读取属性值。
"""
if value is None:
return None
if isinstance(key, str) and key in ("count", "length") and hasattr(value, "__len__"):
return len(value)
if isinstance(value, dict):
return value.get(key)
if isinstance(value, (list, tuple)):
if isinstance(key, int) and 0 <= key < len(value):
return value[key]
return None
if isinstance(key, str) and hasattr(value, key):
return getattr(value, key)
return None
@staticmethod
def compare_values(left: Any, operator: ast.cmpop, right: Any) -> bool:
"""
比较两个条件表达式值。
"""
try:
if isinstance(operator, ast.Eq):
return bool(left == right)
if isinstance(operator, ast.NotEq):
return bool(left != right)
if isinstance(operator, ast.Gt):
return bool(left > right)
if isinstance(operator, ast.GtE):
return bool(left >= right)
if isinstance(operator, ast.Lt):
return bool(left < right)
if isinstance(operator, ast.LtE):
return bool(left <= right)
if isinstance(operator, ast.In):
return left in right
if isinstance(operator, ast.NotIn):
return left not in right
except TypeError:
return False
raise ValueError(f"不支持的比较操作符:{operator.__class__.__name__}")
def merge_context(self, context: ActionContext) -> None:
"""
合并上下文
"""
if not context:
return
for key in context.__class__.model_fields:
value = getattr(context, key, None)
if key in ("execute_history", "progress") or value in (None, "", [], {}):
continue
current_value = getattr(self.context, key, None)
if isinstance(value, list):
if current_value is None:
setattr(self.context, key, value)
continue
for item in value:
if item not in current_value:
current_value.append(item)
elif isinstance(value, dict):
if not current_value:
setattr(self.context, key, value)
else:
current_value.update(value)
elif not current_value:
setattr(self.context, key, value)
class WorkflowChain(ChainBase):
"""
工作流链
"""
@eventmanager.register(EventType.WorkflowExecute)
def event_process(self, event: Event) -> None:
"""
事件触发工作流执行
"""
if not isinstance(event.event_data, dict):
return
workflow_id = event.event_data.get('workflow_id')
if not workflow_id:
return
self.process(workflow_id, from_begin=False)
@staticmethod
def process(
workflow_id: int,
from_begin: Optional[bool] = True,
progress_callback: Optional[Callable[..., None]] = None,
) -> Tuple[bool, str]:
"""
处理工作流
:param workflow_id: 工作流ID
:param from_begin: 是否从头开始,默认为True
:param progress_callback: 定时服务进度更新回调
"""
workflow_execution = get_configured_workflow_execution()
workflow = get_configured_workflow_query().get_sync(workflow_id)
if not workflow:
logger.warn(f"工作流 {workflow_id} 不存在")
return False, "工作流不存在"
try:
validate_workflow_graph(workflow.actions, workflow.flows)
except ValueError as err:
return False, str(err)
if not workflow.actions:
logger.warn(f"工作流 {workflow.name} 无动作")
return False, "工作流无动作"
def save_step(
action: Action,
context: ActionContext,
execution_state: dict[str, Any],
completed: bool,
) -> None:
"""
保存动作上下文和结构化执行状态。
"""
persisted_action_id = (action.id or "") if completed else ""
workflow_execution.step(
workflow_id,
action_id=persisted_action_id,
context=_serialize_workflow_context(context),
execution_state=_serialize_workflow_value(execution_state)
)
if progress_callback:
runtime = execution_state.get("runtime") or {}
finished_actions = int(runtime.get("finished_actions") or 0)
total_actions = len(workflow.actions)
progress_callback(
value=finished_actions / total_actions * 100,
text=(
f"工作流动作({finished_actions}/{total_actions})"
f"{action.name or action.type or action.id} "
f"{'执行完成' if completed else '执行中'}"
),
data={
"total": total_actions,
"finished": finished_actions,
"current": action.id,
},
)
def commit_terminal(execution: WorkflowExecutor) -> None:
"""
同 ID 所有权持有期间提交最后一次结果,避免覆盖后续执行的状态。
"""
if execution.stopped:
return
if not execution.success or execution.has_failure:
committed = workflow_execution.fail(workflow_id, result=execution.errmsg)
else:
committed = workflow_execution.success(workflow_id)
if not committed:
raise RuntimeError("工作流终态写入失败")
executor = WorkflowExecutor(
workflow, step_callback=save_step, from_begin=bool(from_begin), completion_callback=commit_terminal,
)
if not executor.admit():
logger.warning("%s,拒绝执行 %s", executor.errmsg, workflow.name)
return False, executor.errmsg
started, message = WorkflowChain._start_execution(
executor, workflow_execution, bool(from_begin), progress_callback,
)
if not started:
return False, message
executor.execute()
if executor.stopped:
logger.info(f"工作流 {workflow.name} 已停止")
return False, executor.errmsg
if not executor.success or executor.has_failure:
logger.info(f"工作流 {workflow.name} 执行失败:{executor.errmsg}")
return False, executor.errmsg
logger.info(f"工作流 {workflow.name} 执行完成")
if progress_callback:
progress_callback(value=100, text=f"工作流 {workflow.name} 执行完成")
return True, ""
@staticmethod
def _start_execution(
executor: WorkflowExecutor,
execution: WorkflowExecutionPort,
from_begin: bool,
progress_callback: Optional[Callable[..., None]],
) -> Tuple[bool, str]:
"""
仅在获得同 ID 准入后重置并启动;事务拒绝或异常均释放未执行 任务持有者。
"""
workflow = executor.workflow
try:
if from_begin and not execution.reset(workflow.id):
executor.abort_before_execute()
return False, "工作流重置失败"
if not execution.start(workflow.id):
executor.abort_before_execute()
return False, "工作流启动失败"
logger.info(f"开始执行工作流 {workflow.name},共 {len(workflow.actions)} 个动作 ...")
if progress_callback:
progress_callback(
value=0,
text=f"开始执行工作流 {workflow.name} ...",
data={"total": len(workflow.actions), "finished": 0},
)
except Exception:
executor.abort_before_execute()
raise
return True, ""
@staticmethod
def get_workflows() -> List[WorkflowSnapshot]:
"""
获取工作流列表
"""
return get_configured_workflow_query().list_enabled()
@staticmethod
def get_timer_workflows() -> List[WorkflowSnapshot]:
"""
获取定时触发的工作流列表
"""
return get_configured_workflow_query().list_timer_enabled()
@staticmethod
def get_event_workflows() -> List[WorkflowSnapshot]:
"""
获取事件触发的工作流列表
"""
return get_configured_workflow_query().list_event_enabled()