chore(repo): initialize team collaboration repository
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""无 I/O 的编排规则。"""
|
||||
|
||||
from .states import IllegalTransition, assert_run_transition
|
||||
|
||||
__all__ = ["IllegalTransition", "assert_run_transition"]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Materialize a published Workflow snapshot into sequential RunStep rows.
|
||||
|
||||
The planner deliberately supports a small, deterministic subset of the workflow
|
||||
schema. It never evaluates expressions and it never turns free-form text into a
|
||||
command. Host Agents only receive already-published adapter/template references.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..enums import OnFail, StepType
|
||||
from ..models import Run, RunStep, Workflow
|
||||
|
||||
_PARAM = re.compile(r"^\{\{\s*params\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$")
|
||||
_LOOP_INDEX = re.compile(r"^\{\{\s*loop\.index\s*\}\}$")
|
||||
_HOST_STEP_TYPES = {
|
||||
StepType.COMMAND.value,
|
||||
StepType.DEVICE_CHECK.value,
|
||||
StepType.WAIT.value,
|
||||
}
|
||||
|
||||
|
||||
class WorkflowPlanningError(ValueError):
|
||||
"""The published workflow cannot be safely materialized."""
|
||||
|
||||
|
||||
def _resolve(value: Any, run_params: Dict[str, Any], loop_index: Optional[int]) -> Any:
|
||||
"""Resolve only exact, allow-listed template tokens; no expression evaluation."""
|
||||
|
||||
if isinstance(value, str):
|
||||
parameter = _PARAM.fullmatch(value)
|
||||
if parameter:
|
||||
key = parameter.group(1)
|
||||
if key not in run_params:
|
||||
raise WorkflowPlanningError("工作流参数不存在: {0}".format(key))
|
||||
return run_params[key]
|
||||
if _LOOP_INDEX.fullmatch(value):
|
||||
if loop_index is None:
|
||||
raise WorkflowPlanningError("loop.index 只能在循环体内使用")
|
||||
return loop_index
|
||||
if "{{" in value or "}}" in value:
|
||||
raise WorkflowPlanningError("模板只允许完整的 params.X 或 loop.index 占位符")
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {key: _resolve(item, run_params, loop_index) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_resolve(item, run_params, loop_index) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _step_row(
|
||||
*,
|
||||
run: Run,
|
||||
spec: Dict[str, Any],
|
||||
seq: int,
|
||||
loop_index: Optional[int],
|
||||
checkpoint_after: bool,
|
||||
) -> RunStep:
|
||||
step_type = str(spec.get("type", ""))
|
||||
if step_type not in _HOST_STEP_TYPES:
|
||||
raise WorkflowPlanningError("Host Agent 不支持步骤类型: {0}".format(step_type or "<empty>"))
|
||||
key = str(spec.get("key", "")).strip()
|
||||
adapter = str(spec.get("adapter", "")).strip()
|
||||
if not key or not adapter:
|
||||
raise WorkflowPlanningError("步骤 key 与 adapter 不能为空")
|
||||
|
||||
retry = int(spec.get("retry", 0))
|
||||
timeout_s = float(spec.get("timeout_s", get_settings().timing.step_default_timeout_s))
|
||||
if retry < 0 or retry > 20:
|
||||
raise WorkflowPlanningError("步骤 retry 必须在 0..20 之间")
|
||||
if timeout_s <= 0:
|
||||
raise WorkflowPlanningError("步骤 timeout_s 必须大于 0")
|
||||
|
||||
return RunStep(
|
||||
run_id=run.id,
|
||||
seq=seq,
|
||||
loop_index=loop_index,
|
||||
step_key=key,
|
||||
step_type=step_type,
|
||||
adapter=adapter,
|
||||
template=spec.get("template"),
|
||||
params=_resolve(dict(spec.get("params") or {}), run.params, loop_index),
|
||||
max_retry=retry,
|
||||
timeout_s=timeout_s,
|
||||
idempotent=bool(spec.get("idempotent", False)),
|
||||
danger=str(spec.get("danger", "none")),
|
||||
on_fail=str(spec.get("on_fail", OnFail.RETRY.value)),
|
||||
checkpoint_after=checkpoint_after,
|
||||
)
|
||||
|
||||
|
||||
def plan_run_steps(run: Run, workflow: Workflow) -> List[RunStep]:
|
||||
"""Expand one workflow snapshot into the exact ordered steps for a Run."""
|
||||
|
||||
if not workflow.published:
|
||||
raise WorkflowPlanningError("工作流尚未发布")
|
||||
raw_steps = workflow.spec.get("steps") if isinstance(workflow.spec, dict) else None
|
||||
if not isinstance(raw_steps, list) or not raw_steps:
|
||||
raise WorkflowPlanningError("工作流没有可执行步骤")
|
||||
|
||||
planned: List[RunStep] = []
|
||||
seq = 1
|
||||
for raw in raw_steps:
|
||||
if not isinstance(raw, dict):
|
||||
raise WorkflowPlanningError("工作流步骤必须是 object")
|
||||
step_type = str(raw.get("type", ""))
|
||||
|
||||
# Safety preflight and evidence/report generation live in the control plane.
|
||||
if step_type in {StepType.PRECHECK.value, StepType.REPORT.value}:
|
||||
continue
|
||||
|
||||
if step_type == StepType.LOOP.value:
|
||||
body = raw.get("body")
|
||||
if not isinstance(body, list) or not body:
|
||||
raise WorkflowPlanningError("循环步骤必须包含非空 body")
|
||||
for loop_index in range(1, run.loop_target + 1):
|
||||
for index, child in enumerate(body):
|
||||
if not isinstance(child, dict):
|
||||
raise WorkflowPlanningError("循环体步骤必须是 object")
|
||||
planned.append(
|
||||
_step_row(
|
||||
run=run,
|
||||
spec=child,
|
||||
seq=seq,
|
||||
loop_index=loop_index,
|
||||
checkpoint_after=bool(child.get("checkpoint", False))
|
||||
or (bool(raw.get("checkpoint", False)) and index == len(body) - 1),
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
continue
|
||||
|
||||
planned.append(
|
||||
_step_row(
|
||||
run=run,
|
||||
spec=raw,
|
||||
seq=seq,
|
||||
loop_index=None,
|
||||
checkpoint_after=bool(raw.get("checkpoint", False)),
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
|
||||
if not planned:
|
||||
raise WorkflowPlanningError("工作流没有 Host Agent 可执行步骤")
|
||||
return planned
|
||||
|
||||
|
||||
async def materialize_run_steps(
|
||||
session: AsyncSession, run: Run, workflow: Workflow
|
||||
) -> List[RunStep]:
|
||||
planned = plan_run_steps(run, workflow)
|
||||
session.add_all(planned)
|
||||
await session.flush()
|
||||
return planned
|
||||
@@ -0,0 +1,37 @@
|
||||
"""五级恢复阶梯的纯函数决策。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..enums import RecoveryLevel, RecoveryTrigger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryContext:
|
||||
trigger: RecoveryTrigger
|
||||
attempt: int
|
||||
agent_online: bool
|
||||
host_network_online: bool
|
||||
oob_online: bool
|
||||
|
||||
|
||||
def next_recovery_level(context: RecoveryContext) -> RecoveryLevel:
|
||||
if context.trigger == RecoveryTrigger.DATA_INTEGRITY:
|
||||
return RecoveryLevel.FREEZE
|
||||
|
||||
ladder = []
|
||||
if context.agent_online:
|
||||
ladder.append(RecoveryLevel.AGENT_SOFT)
|
||||
if context.host_network_online:
|
||||
ladder.append(RecoveryLevel.OS_REBOOT)
|
||||
if context.oob_online:
|
||||
ladder.extend(
|
||||
[
|
||||
RecoveryLevel.OOB_RESET,
|
||||
RecoveryLevel.OOB_ATX_POWER,
|
||||
RecoveryLevel.OOB_AC_CYCLE,
|
||||
]
|
||||
)
|
||||
if context.attempt >= len(ladder):
|
||||
return RecoveryLevel.FREEZE
|
||||
return ladder[context.attempt]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Run 状态转移表。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, FrozenSet
|
||||
|
||||
from ..enums import RunState
|
||||
|
||||
|
||||
class IllegalTransition(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
RUN_TRANSITIONS: Dict[RunState, FrozenSet[RunState]] = {
|
||||
RunState.QUEUED: frozenset({RunState.PREFLIGHT, RunState.ABORTED}),
|
||||
RunState.PREFLIGHT: frozenset(
|
||||
{RunState.RUNNING, RunState.REJECTED, RunState.ABORTED}
|
||||
),
|
||||
RunState.RUNNING: frozenset(
|
||||
{
|
||||
RunState.RECOVERING,
|
||||
RunState.PAUSED,
|
||||
RunState.FROZEN,
|
||||
RunState.COMPLETED,
|
||||
RunState.ABORTED,
|
||||
}
|
||||
),
|
||||
RunState.RECOVERING: frozenset(
|
||||
{RunState.RUNNING, RunState.FROZEN, RunState.ABORTED}
|
||||
),
|
||||
RunState.PAUSED: frozenset({RunState.RUNNING, RunState.ABORTED}),
|
||||
RunState.FROZEN: frozenset(),
|
||||
RunState.COMPLETED: frozenset(),
|
||||
RunState.ABORTED: frozenset(),
|
||||
RunState.REJECTED: frozenset(),
|
||||
}
|
||||
|
||||
|
||||
def assert_run_transition(current: str, target: str) -> None:
|
||||
current_state = RunState(current)
|
||||
target_state = RunState(target)
|
||||
if target_state not in RUN_TRANSITIONS[current_state]:
|
||||
raise IllegalTransition(
|
||||
"Run 状态不允许从 {0} 转到 {1}".format(current_state.value, target_state.value)
|
||||
)
|
||||
Reference in New Issue
Block a user