2026-08-11 00:54:05 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 房间内自动化 v1(moduleId: core-automation, 可复用 ✓)
|
|
|
|
|
|
# plan.md §5.3(自动化档位 G0-G4)+ §6.5(房间内自动化)
|
|
|
|
|
|
# - Gear 状态机:G0 人工 / G1 只读建议 / G2 沙盒执行 /
|
|
|
|
|
|
# G3 门禁确认后执行 / G4 受控自动执行;升权必须走 harness 门禁
|
|
|
|
|
|
# - RuleExecutor:事件/定时/阈值规则 + 注册表 + 按档位分发
|
|
|
|
|
|
# - AutomationScheduler:轻量调度器(间隔/定时触发,可暂停/恢复)
|
|
|
|
|
|
# - 每次触发可暂停、回放、审计(automation.run 含 gear/rule/trigger)
|
|
|
|
|
|
# 约束:纯标准库 + 既有 harness/audit;不新增 _POWER_MAP 条目
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
import copy
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
2026-08-11 00:54:05 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import time
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from collections.abc import Callable
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from server.agent_core import harness
|
|
|
|
|
|
from server.agent_core.audit import write_audit
|
|
|
|
|
|
from server.timeutil import fmt_dt
|
|
|
|
|
|
|
|
|
|
|
|
# 档位代码(plan.md §5.3):每场景/每产线可配,升权必须过门禁
|
|
|
|
|
|
GEAR_CODES = ("G0", "G1", "G2", "G3", "G4")
|
|
|
|
|
|
# 触发动作(plan.md §6.5 RoomAutomation.action)
|
|
|
|
|
|
VALID_ACTIONS = ("notify", "suggest", "re-plan", "commit", "reschedule")
|
|
|
|
|
|
# 外部事件源(plan.md §6.5)
|
|
|
|
|
|
VALID_SOURCES = ("WMS", "QMS", "ERP")
|
|
|
|
|
|
# 升权门禁动作:刻意不登记进 _POWER_MAP → harness 按未登记动作默认 P3
|
|
|
|
|
|
# (fail-closed,双人确认 SOD),改门禁必须过门禁(plan.md §8.3 策略配置)
|
|
|
|
|
|
ESCALATION_ACTION = "automation.gear.escalate"
|
|
|
|
|
|
|
|
|
|
|
|
# 房间动作 → 门禁动作映射(执行侧复用既有权力矩阵,不新增 _POWER_MAP 条目)
|
|
|
|
|
|
_ACTION_HARNESS_MAP: dict[str, str | None] = {
|
|
|
|
|
|
"suggest": None, # 纯提议(P0),无门禁动作
|
|
|
|
|
|
"notify": "assistant.reply", # P0:主动播报/通知
|
|
|
|
|
|
"re-plan": "schedule.run", # P1:试排写草稿(沙盒语义)
|
|
|
|
|
|
"commit": "schedule.publish", # P2:写主干 → 确认卡(G4 受控自动除外)
|
|
|
|
|
|
"reschedule": "flex.reschedule", # P2:分级重排(写 flex 主干 → 确认卡)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 真实业务动作注册表(round-40 方向 S · 矩阵 76) ----------------
|
|
|
|
|
|
# 规则动作 → 真实业务意图:发布 = schedule.publish、重排 = flex.reschedule。
|
|
|
|
|
|
# 执行仍走既有 handle_intent / execute_confirmed 门禁(P2 确认卡;G4 受控自动
|
|
|
|
|
|
# 经 AutomationGate 升权凭据后由业务桥自动批准执行——真实写入始终过 harness)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class BusinessActionBinding:
|
|
|
|
|
|
"""自动化规则动作 → 真实业务意图 的接线描述(rule action → intent 构造 + 门禁路径)。"""
|
|
|
|
|
|
|
|
|
|
|
|
action: str # 规则动作(Rule.action,如 commit / reschedule)
|
|
|
|
|
|
intent: str # 真实意图(IntentResult.intent,如 schedule.publish / flex.reschedule)
|
|
|
|
|
|
gate_path: str = "P2" # 门禁路径:P2 出卡(handle_intent 同款)→ execute_confirmed 放行
|
|
|
|
|
|
params: dict[str, Any] = field(default_factory=dict) # 意图槽位模板(规则 params 覆盖)
|
|
|
|
|
|
description: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BUSINESS_ACTION_BINDINGS: dict[str, BusinessActionBinding] = {
|
|
|
|
|
|
"commit": BusinessActionBinding("commit", "schedule.publish", "P2", {},
|
|
|
|
|
|
"发布当前排产版本"),
|
|
|
|
|
|
"reschedule": BusinessActionBinding("reschedule", "flex.reschedule", "P2",
|
|
|
|
|
|
{"level": "L2"}, "分级重排(L2/L3/L4)"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def automation_authorization(
|
|
|
|
|
|
rule: Rule,
|
|
|
|
|
|
binding: BusinessActionBinding | None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
binding_snapshot = None
|
|
|
|
|
|
if binding is not None:
|
|
|
|
|
|
binding_snapshot = {
|
|
|
|
|
|
"action": binding.action,
|
|
|
|
|
|
"intent": binding.intent,
|
|
|
|
|
|
"gatePath": binding.gate_path,
|
|
|
|
|
|
"params": copy.deepcopy(binding.params),
|
|
|
|
|
|
"description": binding.description,
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"schemaVersion": "automation-authorization.v1",
|
|
|
|
|
|
"rule": copy.deepcopy(rule.as_dict()),
|
|
|
|
|
|
"harnessAction": harness_action_for(rule.action),
|
|
|
|
|
|
"power": action_power(rule.action),
|
|
|
|
|
|
"binding": binding_snapshot,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def automation_authorization_digest(authorization: dict[str, Any]) -> str:
|
|
|
|
|
|
payload = json.dumps(
|
|
|
|
|
|
authorization,
|
|
|
|
|
|
ensure_ascii=True,
|
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
|
separators=(",", ":"),
|
|
|
|
|
|
default=str,
|
|
|
|
|
|
).encode("utf-8")
|
|
|
|
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_automation_authorization(
|
|
|
|
|
|
authorization: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
rule_id: str,
|
|
|
|
|
|
room_id: str,
|
|
|
|
|
|
target_gear: Gear,
|
|
|
|
|
|
) -> Rule:
|
|
|
|
|
|
required_top = {"schemaVersion", "rule", "harnessAction", "power", "binding"}
|
|
|
|
|
|
if set(authorization) != required_top:
|
|
|
|
|
|
raise ValueError("自动化授权快照字段不完整")
|
|
|
|
|
|
if authorization.get("schemaVersion") != "automation-authorization.v1":
|
|
|
|
|
|
raise ValueError("自动化授权快照版本不受支持")
|
|
|
|
|
|
raw_rule = authorization.get("rule")
|
|
|
|
|
|
required_rule = {
|
|
|
|
|
|
"ruleId", "roomId", "trigger", "gear", "action", "guardrails",
|
|
|
|
|
|
"enabled", "description", "params",
|
|
|
|
|
|
}
|
|
|
|
|
|
if not isinstance(raw_rule, dict) or set(raw_rule) != required_rule:
|
|
|
|
|
|
raise ValueError("自动化授权缺少完整 Rule 快照")
|
|
|
|
|
|
try:
|
|
|
|
|
|
rule = Rule.from_dict(raw_rule)
|
|
|
|
|
|
rule.validate()
|
|
|
|
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
|
|
|
|
raise ValueError(f"自动化授权 Rule 无效:{exc}") from exc
|
|
|
|
|
|
if (
|
|
|
|
|
|
rule.rule_id != rule_id
|
|
|
|
|
|
or rule.room_id != room_id
|
|
|
|
|
|
or rule.gear != target_gear.code
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("自动化授权 Rule 作用域不一致")
|
|
|
|
|
|
expected_harness_action = harness_action_for(rule.action)
|
|
|
|
|
|
expected_power = action_power(rule.action)
|
|
|
|
|
|
if authorization.get("harnessAction") != expected_harness_action:
|
|
|
|
|
|
raise ValueError("自动化授权门禁动作不一致")
|
|
|
|
|
|
if authorization.get("power") != expected_power:
|
|
|
|
|
|
raise ValueError("自动化授权权力等级不一致")
|
|
|
|
|
|
|
|
|
|
|
|
binding = authorization.get("binding")
|
|
|
|
|
|
if target_gear.auto and expected_power in ("P2", "P3"):
|
|
|
|
|
|
required_binding = {"action", "intent", "gatePath", "params", "description"}
|
|
|
|
|
|
if not isinstance(binding, dict) or set(binding) != required_binding:
|
|
|
|
|
|
raise ValueError("G4 写动作必须绑定完整 BusinessActionBinding")
|
|
|
|
|
|
if (
|
|
|
|
|
|
binding.get("action") != rule.action
|
|
|
|
|
|
or binding.get("intent") != expected_harness_action
|
|
|
|
|
|
or binding.get("gatePath") != expected_power
|
|
|
|
|
|
or not isinstance(binding.get("params"), dict)
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("G4 BusinessActionBinding 与规则/门禁不一致")
|
|
|
|
|
|
elif binding is not None:
|
|
|
|
|
|
if not isinstance(binding, dict) or binding.get("action") != rule.action:
|
|
|
|
|
|
raise ValueError("自动化授权 BusinessActionBinding 无效")
|
|
|
|
|
|
return rule
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
class Gear:
|
|
|
|
|
|
"""自动化档位(plan.md §5.3):G0-G4 放权递增状态机。
|
|
|
|
|
|
|
|
|
|
|
|
- G0 人工:只出建议,全部人工操作(proposes)
|
|
|
|
|
|
- G1 建议:只读建议(proposes)
|
|
|
|
|
|
- G2 半自动:沙盒执行 P0/P1(sandbox);P2/P3 仍出确认卡
|
|
|
|
|
|
- G3 监督自治:门禁确认后执行(gated);P2 走确认卡,事后通知
|
|
|
|
|
|
- G4 全自治:受控自动执行(auto);仅灰度/夜间滚动排产,全审计
|
|
|
|
|
|
|
|
|
|
|
|
升权(G2→G3/G4)必须走 harness 门禁(AutomationGate):任何
|
|
|
|
|
|
G3/G4 规则在未取得升权凭据前被封顶为 G2 沙盒姿态。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
__slots__ = ("auto", "code", "gated", "level", "name", "proposes", "sandbox")
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, code: str, level: int, name: str, *, proposes: bool,
|
|
|
|
|
|
sandbox: bool, gated: bool, auto: bool) -> None:
|
|
|
|
|
|
self.code = code
|
|
|
|
|
|
self.level = level
|
|
|
|
|
|
self.name = name
|
|
|
|
|
|
self.proposes = proposes
|
|
|
|
|
|
self.sandbox = sandbox
|
|
|
|
|
|
self.gated = gated
|
|
|
|
|
|
self.auto = auto
|
|
|
|
|
|
|
|
|
|
|
|
def __lt__(self, other: object) -> bool:
|
|
|
|
|
|
return self.level < gear_of(other).level
|
|
|
|
|
|
|
|
|
|
|
|
def __le__(self, other: object) -> bool:
|
|
|
|
|
|
return self.level <= gear_of(other).level
|
|
|
|
|
|
|
|
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
|
|
|
|
return isinstance(other, Gear) and self.code == other.code
|
|
|
|
|
|
|
|
|
|
|
|
def __hash__(self) -> int:
|
|
|
|
|
|
return hash(self.code)
|
|
|
|
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
|
|
return f"Gear({self.code})"
|
|
|
|
|
|
|
|
|
|
|
|
def cap(self, other: Gear | str | int) -> Gear:
|
|
|
|
|
|
"""取两档中较低档位(任何放权都不能超过规则允许档位)。"""
|
|
|
|
|
|
other_gear = gear_of(other)
|
|
|
|
|
|
if self.level <= other_gear.level:
|
|
|
|
|
|
return self
|
|
|
|
|
|
return other_gear
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_GEARS: dict[str, Gear] = {
|
|
|
|
|
|
"G0": Gear("G0", 0, "manual", proposes=True, sandbox=False, gated=False, auto=False),
|
|
|
|
|
|
"G1": Gear("G1", 1, "suggest", proposes=True, sandbox=False, gated=False, auto=False),
|
|
|
|
|
|
"G2": Gear("G2", 2, "sandbox", proposes=False, sandbox=True, gated=False, auto=False),
|
|
|
|
|
|
"G3": Gear("G3", 3, "supervised", proposes=False, sandbox=True, gated=True, auto=False),
|
|
|
|
|
|
"G4": Gear("G4", 4, "autonomous", proposes=False, sandbox=True, gated=True, auto=True),
|
|
|
|
|
|
}
|
|
|
|
|
|
G0 = _GEARS["G0"]
|
|
|
|
|
|
G1 = _GEARS["G1"]
|
|
|
|
|
|
G2 = _GEARS["G2"]
|
|
|
|
|
|
G3 = _GEARS["G3"]
|
|
|
|
|
|
G4 = _GEARS["G4"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gear_of(value: Gear | str | int) -> Gear:
|
|
|
|
|
|
"""按代码/档位对象/等级数字解析 Gear;非法输入抛 ValueError。"""
|
|
|
|
|
|
if isinstance(value, Gear):
|
|
|
|
|
|
return value
|
|
|
|
|
|
if isinstance(value, int) and not isinstance(value, bool):
|
|
|
|
|
|
for gear in _GEARS.values():
|
|
|
|
|
|
if gear.level == value:
|
|
|
|
|
|
return gear
|
|
|
|
|
|
raise ValueError(f"未知自动化档位等级 {value},允许 0-4")
|
|
|
|
|
|
key = str(value).upper()
|
|
|
|
|
|
if key not in _GEARS:
|
|
|
|
|
|
raise ValueError(f"未知自动化档位 {value!r},允许:{', '.join(GEAR_CODES)}")
|
|
|
|
|
|
return _GEARS[key]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AutomationError(RuntimeError):
|
|
|
|
|
|
"""自动化规则执行错误(配置缺失/处理器缺失等)。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GateRequiredError(PermissionError):
|
|
|
|
|
|
"""档位升权未通过门禁:需先经 harness 确认卡批准。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 定时触发(轻量 cron 子集 + 间隔) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
def _cron_field(pattern: str, lo: int, hi: int) -> set[int]:
|
|
|
|
|
|
"""解析单个 cron 字段(*、*/n、a-b、a-b/n、a,b),返回合法值集合。"""
|
|
|
|
|
|
values: set[int] = set()
|
|
|
|
|
|
for raw in pattern.split(","):
|
|
|
|
|
|
part = raw.strip()
|
|
|
|
|
|
if not part:
|
|
|
|
|
|
raise ValueError("cron 字段存在空项")
|
|
|
|
|
|
step = 1
|
|
|
|
|
|
if "/" in part:
|
|
|
|
|
|
part, _, step_text = part.partition("/")
|
|
|
|
|
|
step = int(step_text)
|
|
|
|
|
|
if step <= 0:
|
|
|
|
|
|
raise ValueError(f"cron 步长必须为正数:{step_text!r}")
|
|
|
|
|
|
if part == "*":
|
|
|
|
|
|
start, end = lo, hi
|
|
|
|
|
|
elif "-" in part:
|
|
|
|
|
|
left, _, right = part.partition("-")
|
|
|
|
|
|
start, end = int(left), int(right)
|
|
|
|
|
|
else:
|
|
|
|
|
|
start = end = int(part)
|
|
|
|
|
|
if not (lo <= start <= end <= hi):
|
|
|
|
|
|
raise ValueError(f"cron 字段越界:{raw!r}(允许 {lo}-{hi})")
|
|
|
|
|
|
values.update(range(start, end + 1, step))
|
|
|
|
|
|
return values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class CronExpr:
|
|
|
|
|
|
"""cron 表达式(5 字段:分 时 日 月 周;周 0/7=周日)。"""
|
|
|
|
|
|
|
|
|
|
|
|
minutes: frozenset[int]
|
|
|
|
|
|
hours: frozenset[int]
|
|
|
|
|
|
doms: frozenset[int]
|
|
|
|
|
|
months: frozenset[int]
|
|
|
|
|
|
dows: frozenset[int] # Python weekday() 语义:周一=0 … 周日=6
|
|
|
|
|
|
|
|
|
|
|
|
def matches(self, moment: datetime) -> bool:
|
|
|
|
|
|
return (
|
|
|
|
|
|
moment.minute in self.minutes
|
|
|
|
|
|
and moment.hour in self.hours
|
|
|
|
|
|
and moment.day in self.doms
|
|
|
|
|
|
and moment.month in self.months
|
|
|
|
|
|
and moment.weekday() in self.dows
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_cron(expr: str) -> CronExpr:
|
|
|
|
|
|
"""解析 5 字段 cron 表达式(分钟粒度;支持 *、*/n、a-b、a,b 子集)。"""
|
|
|
|
|
|
parts = expr.split()
|
|
|
|
|
|
if len(parts) != 5:
|
|
|
|
|
|
raise ValueError("cron 需要 5 个字段:分 时 日 月 周")
|
|
|
|
|
|
dows_raw = _cron_field(parts[4], 0, 7)
|
|
|
|
|
|
return CronExpr(
|
|
|
|
|
|
minutes=frozenset(_cron_field(parts[0], 0, 59)),
|
|
|
|
|
|
hours=frozenset(_cron_field(parts[1], 0, 23)),
|
|
|
|
|
|
doms=frozenset(_cron_field(parts[2], 1, 31)),
|
|
|
|
|
|
months=frozenset(_cron_field(parts[3], 1, 12)),
|
|
|
|
|
|
dows=frozenset((day + 6) % 7 for day in dows_raw),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cron_next(expr: str | CronExpr, after: datetime) -> datetime | None:
|
|
|
|
|
|
"""返回 after(不含)之后的下一个匹配时刻,最远搜索 366 天。"""
|
|
|
|
|
|
cron = expr if isinstance(expr, CronExpr) else parse_cron(expr)
|
|
|
|
|
|
cursor = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
|
|
|
|
|
horizon = after + timedelta(days=366)
|
|
|
|
|
|
while cursor <= horizon:
|
|
|
|
|
|
if cron.matches(cursor):
|
|
|
|
|
|
return cursor
|
|
|
|
|
|
cursor += timedelta(minutes=1)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 规则定义与注册表 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Rule:
|
|
|
|
|
|
"""房间内自动化规则(plan.md §6.5 RoomAutomation 的 Python 形态)。
|
|
|
|
|
|
|
|
|
|
|
|
trigger 支持三种:event(source+type)、schedule(cron 或 every 秒)、
|
|
|
|
|
|
threshold(metric+op+value)。gear 为该自动化允许的最高档位。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
rule_id: str
|
|
|
|
|
|
room_id: str
|
|
|
|
|
|
trigger: dict[str, Any]
|
|
|
|
|
|
gear: str = "G1"
|
|
|
|
|
|
action: str = "notify"
|
|
|
|
|
|
guardrails: list[str] = field(default_factory=list)
|
|
|
|
|
|
enabled: bool = True
|
|
|
|
|
|
description: str = ""
|
|
|
|
|
|
params: dict[str, Any] = field(default_factory=dict) # 业务动作入参(如重排 level)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def max_gear(self) -> Gear:
|
|
|
|
|
|
return gear_of(self.gear)
|
|
|
|
|
|
|
|
|
|
|
|
def validate(self) -> None:
|
|
|
|
|
|
gear_of(self.gear)
|
|
|
|
|
|
if not isinstance(self.params, dict):
|
|
|
|
|
|
raise TypeError("params 必须是 dict")
|
|
|
|
|
|
if self.action not in VALID_ACTIONS:
|
|
|
|
|
|
raise ValueError(f"未知动作 {self.action!r},允许:{', '.join(VALID_ACTIONS)}")
|
|
|
|
|
|
if not str(self.room_id or "").strip():
|
|
|
|
|
|
raise ValueError("room_id 不能为空")
|
|
|
|
|
|
on = self.trigger.get("on")
|
|
|
|
|
|
if on == "event":
|
|
|
|
|
|
source = str(self.trigger.get("source") or "")
|
|
|
|
|
|
if source not in VALID_SOURCES:
|
|
|
|
|
|
raise ValueError(f"未知事件源 {source!r},允许:{', '.join(VALID_SOURCES)}")
|
|
|
|
|
|
if not str(self.trigger.get("type") or "").strip():
|
|
|
|
|
|
raise ValueError("事件规则必须指定 type")
|
|
|
|
|
|
elif on == "schedule":
|
|
|
|
|
|
if "cron" in self.trigger:
|
|
|
|
|
|
parse_cron(str(self.trigger["cron"]))
|
|
|
|
|
|
elif "every" in self.trigger:
|
|
|
|
|
|
every = self.trigger["every"]
|
|
|
|
|
|
if isinstance(every, bool) or not isinstance(every, (int, float)) or every <= 0:
|
|
|
|
|
|
raise ValueError("定时规则 every 必须是正数(秒)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError("定时规则必须提供 cron 或 every")
|
|
|
|
|
|
elif on == "threshold":
|
|
|
|
|
|
if not str(self.trigger.get("metric") or "").strip():
|
|
|
|
|
|
raise ValueError("阈值规则必须指定 metric")
|
|
|
|
|
|
if self.trigger.get("op") not in (">", "<", ">=", "<="):
|
|
|
|
|
|
raise ValueError("阈值规则 op 必须是 >、<、>= 或 <=")
|
|
|
|
|
|
value = self.trigger.get("value")
|
|
|
|
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
|
|
|
|
raise ValueError("阈值规则 value 必须是数字")
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f"未知触发类型 {on!r},允许:event/schedule/threshold")
|
|
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ruleId": self.rule_id,
|
|
|
|
|
|
"roomId": self.room_id,
|
|
|
|
|
|
"trigger": dict(self.trigger),
|
|
|
|
|
|
"gear": self.gear,
|
|
|
|
|
|
"action": self.action,
|
|
|
|
|
|
"guardrails": list(self.guardrails),
|
|
|
|
|
|
"enabled": self.enabled,
|
|
|
|
|
|
"description": self.description,
|
|
|
|
|
|
"params": dict(self.params),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, data: dict[str, Any]) -> Rule:
|
|
|
|
|
|
"""宽容反序列化(as_dict 的逆映射;缺省字段用默认值)。"""
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
rule_id=str(data["ruleId"]),
|
|
|
|
|
|
room_id=str(data.get("roomId") or ""),
|
|
|
|
|
|
trigger=dict(data.get("trigger") or {}),
|
|
|
|
|
|
gear=str(data.get("gear") or "G1"),
|
|
|
|
|
|
action=str(data.get("action") or "notify"),
|
|
|
|
|
|
guardrails=list(data.get("guardrails") or []),
|
|
|
|
|
|
enabled=bool(data.get("enabled", True)),
|
|
|
|
|
|
description=str(data.get("description") or ""),
|
|
|
|
|
|
params=dict(data.get("params") or {}),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RuleRegistry:
|
|
|
|
|
|
"""规则注册表:登记/注销/查询房间内自动化规则。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, rules: list[Rule] | None = None) -> None:
|
|
|
|
|
|
self._rules: dict[str, Rule] = {}
|
|
|
|
|
|
for rule in rules or []:
|
|
|
|
|
|
self.register(rule)
|
|
|
|
|
|
|
|
|
|
|
|
def register(self, rule: Rule) -> Rule:
|
|
|
|
|
|
rule.validate()
|
|
|
|
|
|
if rule.rule_id in self._rules:
|
|
|
|
|
|
raise ValueError(f"规则 {rule.rule_id} 已注册")
|
|
|
|
|
|
self._rules[rule.rule_id] = rule
|
|
|
|
|
|
return rule
|
|
|
|
|
|
|
|
|
|
|
|
def unregister(self, rule_id: str) -> Rule | None:
|
|
|
|
|
|
return self._rules.pop(rule_id, None)
|
|
|
|
|
|
|
|
|
|
|
|
def get(self, rule_id: str) -> Rule:
|
|
|
|
|
|
return self._rules[rule_id]
|
|
|
|
|
|
|
|
|
|
|
|
def list(self, *, enabled: bool | None = None) -> list[Rule]:
|
|
|
|
|
|
rules = list(self._rules.values())
|
|
|
|
|
|
if enabled is not None:
|
|
|
|
|
|
rules = [rule for rule in rules if rule.enabled == enabled]
|
|
|
|
|
|
return rules
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 触发匹配 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
def matches_event(trigger: dict[str, Any], event: dict[str, Any]) -> bool:
|
|
|
|
|
|
"""事件规则匹配:source 与 type 都命中才算触发。"""
|
|
|
|
|
|
return (
|
|
|
|
|
|
trigger.get("on") == "event"
|
|
|
|
|
|
and trigger.get("source") == event.get("source")
|
|
|
|
|
|
and trigger.get("type") == event.get("type")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def matches_threshold(trigger: dict[str, Any], metrics: dict[str, float]) -> bool:
|
|
|
|
|
|
"""阈值规则匹配:metrics[metric] 满足 op/value 才算触发。"""
|
|
|
|
|
|
if trigger.get("on") != "threshold":
|
|
|
|
|
|
return False
|
|
|
|
|
|
value = metrics.get(str(trigger.get("metric") or ""))
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
target = trigger["value"]
|
|
|
|
|
|
op = trigger.get("op")
|
|
|
|
|
|
if op == ">":
|
|
|
|
|
|
return value > target
|
|
|
|
|
|
if op == "<":
|
|
|
|
|
|
return value < target
|
|
|
|
|
|
if op == ">=":
|
|
|
|
|
|
return value >= target
|
|
|
|
|
|
if op == "<=":
|
|
|
|
|
|
return value <= target
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 升权门禁(G3/G4 必须经 harness) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class EscalationGrant:
|
|
|
|
|
|
"""升权凭据:规则获得在 target_gear 档位运行的显式门禁授权(TTL 内有效)。"""
|
|
|
|
|
|
|
|
|
|
|
|
rule_id: str
|
|
|
|
|
|
target_gear: Gear
|
|
|
|
|
|
confirm_id: str
|
|
|
|
|
|
granted_at: float
|
|
|
|
|
|
expires_at: float
|
2026-08-26 00:25:46 +08:00
|
|
|
|
authorization_digest: str = ""
|
|
|
|
|
|
authorization: dict[str, Any] = field(default_factory=dict)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
def is_valid(self, now: float | None = None) -> bool:
|
|
|
|
|
|
moment = now if now is not None else time.time()
|
|
|
|
|
|
return moment <= self.expires_at
|
|
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ruleId": self.rule_id,
|
|
|
|
|
|
"targetGear": self.target_gear.code,
|
|
|
|
|
|
"confirmId": self.confirm_id,
|
|
|
|
|
|
"grantedAtEpoch": self.granted_at,
|
|
|
|
|
|
"expiresAtEpoch": self.expires_at,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"authorizationDigest": self.authorization_digest,
|
|
|
|
|
|
"authorization": copy.deepcopy(self.authorization),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AutomationGate:
|
|
|
|
|
|
"""档位升权门禁:G3/G4 升权必须经 harness.stage_confirmation(§3.3)。
|
|
|
|
|
|
|
|
|
|
|
|
升权动作刻意使用未登记动作 automation.gear.escalate → harness 按
|
|
|
|
|
|
未登记动作默认 P3(fail-closed),需双人确认(SOD)后才颁发凭据。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, grant_ttl_seconds: float | None = None) -> None:
|
|
|
|
|
|
self._grants: dict[str, EscalationGrant] = {}
|
|
|
|
|
|
default_ttl = 24 * 3600
|
|
|
|
|
|
self._grant_ttl_seconds = default_ttl if grant_ttl_seconds is None else grant_ttl_seconds
|
|
|
|
|
|
|
|
|
|
|
|
def request(self, session_id: str, rule_id: str, target_gear: str | Gear, *,
|
|
|
|
|
|
room_id: str = "", reason: str = "", title: str | None = None,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
summary_lines: list[str] | None = None,
|
|
|
|
|
|
authorization: dict[str, Any] | None = None) -> dict[str, Any]:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""出升权确认卡(P3,双人确认;动作此刻并未执行)。"""
|
|
|
|
|
|
gear = gear_of(target_gear)
|
|
|
|
|
|
if gear.level <= G2.level:
|
|
|
|
|
|
raise ValueError(f"{gear.code} 无需升权门禁,仅 G3/G4 需要")
|
2026-08-26 00:25:46 +08:00
|
|
|
|
authorization_snapshot = copy.deepcopy(authorization or {})
|
|
|
|
|
|
if gear.auto and not authorization_snapshot:
|
|
|
|
|
|
raise ValueError("G4 升权必须绑定完整规则与业务动作载荷")
|
|
|
|
|
|
if authorization_snapshot:
|
|
|
|
|
|
validate_automation_authorization(
|
|
|
|
|
|
authorization_snapshot,
|
|
|
|
|
|
rule_id=rule_id,
|
|
|
|
|
|
room_id=room_id,
|
|
|
|
|
|
target_gear=gear,
|
|
|
|
|
|
)
|
|
|
|
|
|
authorization_digest = (
|
|
|
|
|
|
automation_authorization_digest(authorization_snapshot)
|
|
|
|
|
|
if authorization_snapshot
|
|
|
|
|
|
else ""
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
params: dict[str, Any] = {
|
|
|
|
|
|
"ruleId": rule_id,
|
|
|
|
|
|
"roomId": room_id,
|
|
|
|
|
|
"gear": gear.code,
|
|
|
|
|
|
"reason": reason,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"authorization": authorization_snapshot,
|
|
|
|
|
|
"authorizationDigest": authorization_digest,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
block = harness.stage_confirmation(
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
ESCALATION_ACTION,
|
|
|
|
|
|
params,
|
|
|
|
|
|
title=title or f"自动化档位升权 {gear.code}",
|
|
|
|
|
|
summary_lines=list(summary_lines or [f"规则 {rule_id} 申请升权至 {gear.code}(受控自动化)"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"confirmId": str(block.props["confirmId"]),
|
|
|
|
|
|
"action": ESCALATION_ACTION,
|
|
|
|
|
|
"power": str(block.props["power"]),
|
|
|
|
|
|
"params": params,
|
|
|
|
|
|
"gear": gear.code,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def approve(self, confirm_id: str, approve: bool = True, note: str | None = None) -> dict[str, Any] | None:
|
|
|
|
|
|
"""处理一重升权审批(P3 需两重且由不同用户完成)。"""
|
|
|
|
|
|
return harness.take_confirmation(confirm_id, approve=approve, note=note)
|
|
|
|
|
|
|
|
|
|
|
|
def grant_from_decision(self, confirm_id: str, decision: dict[str, Any], *,
|
|
|
|
|
|
rule_id: str, params: dict[str, Any]) -> EscalationGrant:
|
|
|
|
|
|
"""审批完成后凭一次性执行凭据换取升权授权。"""
|
|
|
|
|
|
grant_token = decision.get("executionGrant")
|
|
|
|
|
|
if not grant_token:
|
|
|
|
|
|
raise GateRequiredError("升权确认尚未完成(P3 需第二重审批)")
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if str(params.get("ruleId") or "") != rule_id:
|
|
|
|
|
|
raise GateRequiredError("升权规则标识与批准载荷不一致")
|
|
|
|
|
|
gear = gear_of(str(params.get("gear") or "G3"))
|
|
|
|
|
|
authorization_snapshot = copy.deepcopy(params.get("authorization") or {})
|
|
|
|
|
|
authorization_digest = str(params.get("authorizationDigest") or "")
|
|
|
|
|
|
if gear.auto:
|
|
|
|
|
|
if not authorization_snapshot or not authorization_digest:
|
|
|
|
|
|
raise GateRequiredError("G4 升权缺少规则载荷绑定")
|
|
|
|
|
|
if not hmac.compare_digest(
|
|
|
|
|
|
authorization_digest,
|
|
|
|
|
|
automation_authorization_digest(authorization_snapshot),
|
|
|
|
|
|
):
|
|
|
|
|
|
raise GateRequiredError("G4 升权规则载荷摘要不一致")
|
|
|
|
|
|
try:
|
|
|
|
|
|
validate_automation_authorization(
|
|
|
|
|
|
authorization_snapshot,
|
|
|
|
|
|
rule_id=rule_id,
|
|
|
|
|
|
room_id=str(params.get("roomId") or ""),
|
|
|
|
|
|
target_gear=gear,
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise GateRequiredError(str(exc)) from exc
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if not harness.consume_execution_grant(
|
|
|
|
|
|
grant_token, confirm_id=confirm_id, action=ESCALATION_ACTION, params=params
|
|
|
|
|
|
):
|
|
|
|
|
|
raise GateRequiredError("升权执行凭据无效或已被使用")
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
grant = EscalationGrant(
|
|
|
|
|
|
rule_id=rule_id,
|
|
|
|
|
|
target_gear=gear,
|
|
|
|
|
|
confirm_id=confirm_id,
|
|
|
|
|
|
granted_at=now,
|
|
|
|
|
|
expires_at=now + self._grant_ttl_seconds,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
authorization_digest=authorization_digest,
|
|
|
|
|
|
authorization=authorization_snapshot,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._grants[rule_id] = grant
|
|
|
|
|
|
return grant
|
|
|
|
|
|
|
|
|
|
|
|
def grant_for(self, rule_id: str, now: float | None = None) -> EscalationGrant | None:
|
|
|
|
|
|
"""返回规则当前有效的升权凭据;过期自动清理。"""
|
|
|
|
|
|
grant = self._grants.get(rule_id)
|
|
|
|
|
|
if grant is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not grant.is_valid(now):
|
|
|
|
|
|
self._grants.pop(rule_id, None)
|
|
|
|
|
|
return None
|
|
|
|
|
|
return grant
|
|
|
|
|
|
|
|
|
|
|
|
def revoke(self, rule_id: str) -> bool:
|
|
|
|
|
|
return self._grants.pop(rule_id, None) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 运行记录与执行器 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AutomationRun:
|
|
|
|
|
|
"""一次触发产生的运行记录(供暂停/回放/审计)。"""
|
|
|
|
|
|
|
|
|
|
|
|
run_id: str
|
|
|
|
|
|
rule_id: str
|
|
|
|
|
|
room_id: str
|
|
|
|
|
|
trigger: dict[str, Any]
|
|
|
|
|
|
gear: str
|
|
|
|
|
|
action: str
|
|
|
|
|
|
status: str
|
|
|
|
|
|
at: str
|
|
|
|
|
|
payload: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
audit_id: int | None = None
|
|
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"runId": self.run_id,
|
|
|
|
|
|
"ruleId": self.rule_id,
|
|
|
|
|
|
"roomId": self.room_id,
|
|
|
|
|
|
"trigger": dict(self.trigger),
|
|
|
|
|
|
"gear": self.gear,
|
|
|
|
|
|
"action": self.action,
|
|
|
|
|
|
"status": self.status,
|
|
|
|
|
|
"at": self.at,
|
|
|
|
|
|
"payload": dict(self.payload),
|
|
|
|
|
|
"auditId": self.audit_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, data: dict[str, Any]) -> AutomationRun:
|
|
|
|
|
|
"""宽容反序列化(as_dict 的逆映射;缺省字段用默认值)。"""
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
run_id=str(data["runId"]),
|
|
|
|
|
|
rule_id=str(data.get("ruleId") or ""),
|
|
|
|
|
|
room_id=str(data.get("roomId") or ""),
|
|
|
|
|
|
trigger=dict(data.get("trigger") or {}),
|
|
|
|
|
|
gear=str(data.get("gear") or "G1"),
|
|
|
|
|
|
action=str(data.get("action") or "notify"),
|
|
|
|
|
|
status=str(data.get("status") or "UNKNOWN"),
|
|
|
|
|
|
at=str(data.get("at") or ""),
|
|
|
|
|
|
payload=dict(data.get("payload") or {}),
|
|
|
|
|
|
audit_id=data.get("auditId"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def harness_action_for(action: str) -> str | None:
|
|
|
|
|
|
"""房间动作 → 门禁动作(未映射的纯提议返回 None)。"""
|
|
|
|
|
|
return _ACTION_HARNESS_MAP.get(action)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def action_power(action: str) -> str:
|
|
|
|
|
|
"""房间动作对应的权力等级(按门禁矩阵解析;纯提议为 P0)。"""
|
|
|
|
|
|
name = harness_action_for(action)
|
|
|
|
|
|
if name is None:
|
|
|
|
|
|
return "P0"
|
|
|
|
|
|
return harness.power_of(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _trigger_brief(trigger: dict[str, Any]) -> str:
|
|
|
|
|
|
on = trigger.get("on")
|
|
|
|
|
|
if on == "event":
|
|
|
|
|
|
return f"事件 {trigger.get('source')}/{trigger.get('type')}"
|
|
|
|
|
|
if on == "schedule":
|
|
|
|
|
|
if "cron" in trigger:
|
|
|
|
|
|
return f"定时 {trigger.get('cron')}"
|
|
|
|
|
|
return f"间隔 {trigger.get('every')}s"
|
|
|
|
|
|
if on == "threshold":
|
|
|
|
|
|
return f"阈值 {trigger.get('metric')} {trigger.get('op')} {trigger.get('value')}"
|
|
|
|
|
|
return str(trigger)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RuleExecutor:
|
|
|
|
|
|
"""规则执行器:按档位分发 提议/沙盒执行/确认卡/受控自动执行。
|
|
|
|
|
|
|
|
|
|
|
|
档位姿态(plan.md §5.3):
|
|
|
|
|
|
- G0/G1 只提议(proposes),绝不调用执行处理器;
|
|
|
|
|
|
- G2 沙盒执行 P0/P1(经 harness.guard),P2/P3 只出确认卡;
|
|
|
|
|
|
- G3/G4 必须先取得 AutomationGate 升权凭据,否则封顶 G2 并出升权卡;
|
|
|
|
|
|
- G4 有凭据时允许受控自动执行(含 commit 类 P2,门禁授权 + 全审计)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, registry: RuleRegistry | None = None, gate: AutomationGate | None = None,
|
|
|
|
|
|
handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] | None = None,
|
|
|
|
|
|
actor: str = "automation",
|
|
|
|
|
|
business_bridge: Callable[[BusinessActionBinding, dict[str, Any], dict[str, Any]],
|
|
|
|
|
|
dict[str, Any]] | None = None) -> None:
|
|
|
|
|
|
self.registry = registry if registry is not None else RuleRegistry()
|
|
|
|
|
|
self.gate = gate if gate is not None else AutomationGate()
|
|
|
|
|
|
self.handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = dict(handlers or {})
|
|
|
|
|
|
self.actor = actor
|
|
|
|
|
|
self.runs: dict[str, AutomationRun] = {}
|
|
|
|
|
|
self._run_seq = 0
|
|
|
|
|
|
# 真实业务动作桥(round-40 方向 S):规则动作 → 真实意图(发布/重排)的
|
|
|
|
|
|
# 既有门禁执行通道;未注入时业务动作退化为普通处理器路径(测试桩兼容)。
|
|
|
|
|
|
self.business_bridge = business_bridge
|
|
|
|
|
|
self._business_bindings: dict[str, BusinessActionBinding] = dict(BUSINESS_ACTION_BINDINGS)
|
|
|
|
|
|
|
|
|
|
|
|
def register_handler(self, action: str, handler: Callable[[dict[str, Any]], dict[str, Any]]) -> None:
|
|
|
|
|
|
self.handlers[action] = handler
|
|
|
|
|
|
|
|
|
|
|
|
def register_binding(self, binding: BusinessActionBinding) -> None:
|
|
|
|
|
|
"""登记一条规则动作 → 真实业务意图 的接线(动作注册表)。"""
|
|
|
|
|
|
self._business_bindings[binding.action] = binding
|
|
|
|
|
|
|
|
|
|
|
|
def binding_for(self, action: str) -> BusinessActionBinding | None:
|
|
|
|
|
|
"""查询规则动作是否登记真实业务意图;未登记返回 None(fail-closed)。"""
|
|
|
|
|
|
return self._business_bindings.get(action)
|
|
|
|
|
|
|
|
|
|
|
|
def execute(self, rule: Rule, trigger: dict[str, Any], *,
|
|
|
|
|
|
world: dict[str, Any], next_id: Callable[[str], int],
|
|
|
|
|
|
session_id: str = "automation", save: Callable[[], None] | None = None,
|
|
|
|
|
|
now: datetime | None = None, escalate_on_demand: bool = True) -> AutomationRun:
|
|
|
|
|
|
"""执行一次规则触发:按有效档位分发并写 automation.run 审计。"""
|
|
|
|
|
|
now_dt = now if now is not None else datetime.now(UTC)
|
|
|
|
|
|
declared = rule.max_gear
|
2026-08-26 00:25:46 +08:00
|
|
|
|
declared_power = action_power(rule.action)
|
|
|
|
|
|
declared_binding = self.binding_for(rule.action)
|
|
|
|
|
|
if (
|
|
|
|
|
|
declared.auto
|
|
|
|
|
|
and declared_power in ("P2", "P3")
|
|
|
|
|
|
and (declared_binding is None or self.business_bridge is None)
|
|
|
|
|
|
):
|
|
|
|
|
|
return self._finalize(
|
|
|
|
|
|
world,
|
|
|
|
|
|
next_id,
|
|
|
|
|
|
save,
|
|
|
|
|
|
rule=rule,
|
|
|
|
|
|
trigger=trigger,
|
|
|
|
|
|
gear_code=declared.code,
|
|
|
|
|
|
power="P3",
|
|
|
|
|
|
run_id=self._next_run_id(),
|
|
|
|
|
|
status="DENIED",
|
|
|
|
|
|
now_dt=now_dt,
|
|
|
|
|
|
payload={
|
|
|
|
|
|
"reason": "G4 写动作未绑定完整 BusinessActionBinding 与业务桥",
|
|
|
|
|
|
"unregisteredAction": True,
|
|
|
|
|
|
},
|
|
|
|
|
|
extra={"gateDenied": True, "authorizationBindingMissing": True},
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
grant = self.gate.grant_for(rule.rule_id, now_dt.timestamp())
|
2026-08-26 00:25:46 +08:00
|
|
|
|
authorization_drift = False
|
|
|
|
|
|
if grant is not None and grant.target_gear.auto:
|
|
|
|
|
|
current_authorization = automation_authorization(
|
|
|
|
|
|
rule,
|
|
|
|
|
|
self.binding_for(rule.action),
|
|
|
|
|
|
)
|
|
|
|
|
|
current_digest = automation_authorization_digest(current_authorization)
|
|
|
|
|
|
if (
|
|
|
|
|
|
not grant.authorization_digest
|
|
|
|
|
|
or not hmac.compare_digest(grant.authorization_digest, current_digest)
|
|
|
|
|
|
):
|
|
|
|
|
|
self.gate.revoke(rule.rule_id)
|
|
|
|
|
|
grant = None
|
|
|
|
|
|
authorization_drift = True
|
2026-08-11 00:54:05 +08:00
|
|
|
|
run_id = self._next_run_id()
|
|
|
|
|
|
if declared.level > G2.level and grant is None and not escalate_on_demand:
|
|
|
|
|
|
return self._finalize(
|
|
|
|
|
|
world, next_id, save, rule=rule, trigger=trigger, gear_code=declared.code,
|
|
|
|
|
|
power=action_power(rule.action), run_id=run_id, status="DENIED",
|
|
|
|
|
|
now_dt=now_dt,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
payload={
|
|
|
|
|
|
"reason": (
|
|
|
|
|
|
"G4 升权授权载荷已漂移,旧凭据已撤销"
|
|
|
|
|
|
if authorization_drift
|
|
|
|
|
|
else "档位升权未获门禁授权(G3/G4 必须经 harness 确认)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
extra={"gateDenied": True, "authorizationDrift": authorization_drift},
|
2026-08-11 00:54:05 +08:00
|
|
|
|
)
|
|
|
|
|
|
escalate_confirm_id: str | None = None
|
|
|
|
|
|
effective = declared
|
|
|
|
|
|
if declared.level > G2.level:
|
|
|
|
|
|
if grant is None:
|
|
|
|
|
|
effective = G2
|
|
|
|
|
|
try:
|
|
|
|
|
|
escalate_confirm_id = self._stage_escalation(session_id, rule)
|
|
|
|
|
|
except (PermissionError, ValueError) as exc:
|
|
|
|
|
|
return self._finalize(
|
|
|
|
|
|
world, next_id, save, rule=rule, trigger=trigger,
|
|
|
|
|
|
gear_code=declared.code, power=action_power(rule.action),
|
|
|
|
|
|
run_id=run_id, status="DENIED", now_dt=now_dt,
|
|
|
|
|
|
payload={"reason": f"升权出卡失败:{exc}"},
|
|
|
|
|
|
extra={"gateDenied": True},
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
effective = declared.cap(grant.target_gear)
|
|
|
|
|
|
status, power, payload = self._dispatch(rule, trigger, effective, session_id)
|
|
|
|
|
|
extra: dict[str, Any] = {}
|
|
|
|
|
|
if effective.auto and grant is not None:
|
|
|
|
|
|
extra = {
|
|
|
|
|
|
"escalationConfirmId": grant.confirm_id,
|
|
|
|
|
|
"escalationPower": "P3",
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"authorizationDigest": grant.authorization_digest,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
if escalate_confirm_id is not None:
|
|
|
|
|
|
payload = {"underlying": status, **payload, "escalateConfirmId": escalate_confirm_id}
|
|
|
|
|
|
status = "GATE_REQUIRED"
|
|
|
|
|
|
extra = {
|
|
|
|
|
|
"escalateConfirmId": escalate_confirm_id,
|
|
|
|
|
|
"escalationPower": "P3",
|
|
|
|
|
|
"escalatePending": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
return self._finalize(world, next_id, save, rule=rule, trigger=trigger,
|
|
|
|
|
|
gear_code=effective.code, power=power, run_id=run_id,
|
|
|
|
|
|
status=status, now_dt=now_dt, payload=payload, extra=extra)
|
|
|
|
|
|
|
|
|
|
|
|
def _dispatch(self, rule: Rule, trigger: dict[str, Any], effective: Gear,
|
|
|
|
|
|
session_id: str) -> tuple[str, str, dict[str, Any]]:
|
|
|
|
|
|
"""按有效档位分发到 提议/沙盒执行/确认卡/受控自动(含真实业务动作接线)。
|
|
|
|
|
|
|
|
|
|
|
|
round-40 方向 S(矩阵 76):登记进 BUSINESS_ACTION_BINDINGS 的动作
|
|
|
|
|
|
(commit → schedule.publish、reschedule → flex.reschedule)在注入
|
|
|
|
|
|
business_bridge 时走既有 handle_intent / execute_confirmed 门禁:
|
|
|
|
|
|
G2 沙盒/G3 监督出 P2 确认卡;G4 受控自动(升权凭据已过)自动批准执行。
|
|
|
|
|
|
未登记真实业务动作的 P2/P3 动作即使 G4 有凭据也按 P3 fail-closed 拦截。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if effective.proposes or rule.action == "suggest":
|
|
|
|
|
|
return "PROPOSED", "P0", self._propose(rule, trigger)
|
|
|
|
|
|
power = action_power(rule.action)
|
|
|
|
|
|
harness_action = harness_action_for(rule.action)
|
|
|
|
|
|
params = self._run_params(rule, trigger)
|
|
|
|
|
|
binding = self.binding_for(rule.action)
|
|
|
|
|
|
ctx = {
|
|
|
|
|
|
"rule": rule,
|
|
|
|
|
|
"trigger": dict(trigger),
|
|
|
|
|
|
"gear": effective.code,
|
|
|
|
|
|
"auto": effective.auto,
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
if power in ("P2", "P3"):
|
|
|
|
|
|
if effective.auto:
|
|
|
|
|
|
if harness_action is None:
|
|
|
|
|
|
# 未登记动作(无门禁动作映射)→ fail-closed P3 拦截
|
|
|
|
|
|
return "DENIED", "P3", {
|
|
|
|
|
|
"reason": f"动作 {rule.action} 未登记门禁动作,"
|
|
|
|
|
|
"G4 受控自动仍被 P3 升权门禁拦截(fail-closed)",
|
|
|
|
|
|
"unregisteredAction": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
if binding is not None and self.business_bridge is not None:
|
|
|
|
|
|
# G4 受控自动:AutomationGate 升权凭据(P3 双人确认)已过,
|
|
|
|
|
|
# 经业务桥执行真实业务动作(发布/重排)——真实写入仍过 harness 门禁
|
|
|
|
|
|
return "EXECUTED", power, self.business_bridge(binding, params, ctx)
|
|
|
|
|
|
# P2/P3 动作未登记真实业务动作(无 binding 或未接线业务桥)→ P3 拦截
|
|
|
|
|
|
return "DENIED", "P3", {
|
|
|
|
|
|
"reason": f"动作 {rule.action} 未登记真实业务动作"
|
|
|
|
|
|
"(BUSINESS_ACTION_BINDINGS)或未接线业务桥,"
|
|
|
|
|
|
"G4 受控自动被 P3 门禁拦截(fail-closed)",
|
|
|
|
|
|
"unregisteredAction": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
if binding is not None and self.business_bridge is not None:
|
|
|
|
|
|
# G2 沙盒 / G3 监督:经业务桥走 handle_intent 同款门禁(P2 确认卡)
|
|
|
|
|
|
return "STAGED", power, self.business_bridge(binding, params, ctx)
|
|
|
|
|
|
if harness_action is None:
|
|
|
|
|
|
raise AutomationError(f"动作 {rule.action} 无门禁动作可出卡")
|
|
|
|
|
|
card = harness.stage_confirmation(
|
|
|
|
|
|
session_id, harness_action, params,
|
|
|
|
|
|
title=f"自动化确认:{rule.action}({rule.room_id})",
|
|
|
|
|
|
summary_lines=[
|
|
|
|
|
|
f"规则 {rule.rule_id}(档位 {effective.code})触发 {_trigger_brief(trigger)}",
|
|
|
|
|
|
f"动作 {rule.action} 属于 {power},需人工确认后执行",
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
return "STAGED", power, {
|
|
|
|
|
|
"confirmId": str(card.props["confirmId"]),
|
|
|
|
|
|
"power": power,
|
|
|
|
|
|
"action": harness_action,
|
|
|
|
|
|
}
|
|
|
|
|
|
return "EXECUTED", power, self._run_handler(rule, params)
|
|
|
|
|
|
except (AutomationError, ValueError, PermissionError, KeyError, TypeError) as exc:
|
|
|
|
|
|
return "FAILED", power, {"error": str(exc)}
|
|
|
|
|
|
|
|
|
|
|
|
def _run_handler(self, rule: Rule, params: dict[str, Any], *,
|
|
|
|
|
|
via_guard: bool = True) -> dict[str, Any]:
|
|
|
|
|
|
handler = self.handlers.get(rule.action)
|
|
|
|
|
|
if handler is None:
|
|
|
|
|
|
raise AutomationError(f"未注册动作 {rule.action} 的处理器")
|
|
|
|
|
|
if not via_guard:
|
|
|
|
|
|
# G4 受控自动:升权已过门禁(P3 双人确认),直接执行处理器并全审计
|
|
|
|
|
|
return handler(params)
|
|
|
|
|
|
harness_action = harness_action_for(rule.action)
|
|
|
|
|
|
if harness_action is None:
|
|
|
|
|
|
return handler(params)
|
|
|
|
|
|
return harness.guard(harness_action, params, lambda: handler(params))
|
|
|
|
|
|
|
|
|
|
|
|
def _stage_escalation(self, session_id: str, rule: Rule) -> str:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
authorization = automation_authorization(rule, self.binding_for(rule.action))
|
2026-08-11 00:54:05 +08:00
|
|
|
|
card = self.gate.request(
|
|
|
|
|
|
session_id, rule.rule_id, rule.gear, room_id=rule.room_id,
|
|
|
|
|
|
reason=rule.description or "规则触发升权",
|
2026-08-26 00:25:46 +08:00
|
|
|
|
authorization=authorization,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
summary_lines=[
|
|
|
|
|
|
f"规则 {rule.rule_id} 触发 {_trigger_brief(rule.trigger)}",
|
|
|
|
|
|
f"需升权至 {rule.gear} 才能按声明档位执行",
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
return card["confirmId"]
|
|
|
|
|
|
|
|
|
|
|
|
def _propose(self, rule: Rule, trigger: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"summary": f"房间 {rule.room_id} 规则 {rule.rule_id} 建议执行 {rule.action}",
|
|
|
|
|
|
"trigger": dict(trigger),
|
|
|
|
|
|
"guardrails": list(rule.guardrails),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _run_params(self, rule: Rule, trigger: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ruleId": rule.rule_id,
|
|
|
|
|
|
"roomId": rule.room_id,
|
|
|
|
|
|
"trigger": dict(trigger),
|
|
|
|
|
|
"action": rule.action,
|
|
|
|
|
|
"gear": rule.gear,
|
|
|
|
|
|
"guardrails": list(rule.guardrails),
|
|
|
|
|
|
"payload": dict(rule.params),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _next_run_id(self) -> str:
|
|
|
|
|
|
self._run_seq += 1
|
|
|
|
|
|
return f"ar-{self._run_seq:04d}-{uuid.uuid4().hex[:6]}"
|
|
|
|
|
|
|
|
|
|
|
|
def _finalize(self, world: dict[str, Any], next_id: Callable[[str], int],
|
|
|
|
|
|
save: Callable[[], None] | None, *, rule: Rule, trigger: dict[str, Any],
|
|
|
|
|
|
gear_code: str, power: str, run_id: str, status: str, now_dt: datetime,
|
|
|
|
|
|
payload: dict[str, Any], extra: dict[str, Any]) -> AutomationRun:
|
|
|
|
|
|
audit_id = self._audit_run(
|
|
|
|
|
|
world, next_id, rule=rule, trigger=trigger, gear=gear_code, power=power,
|
|
|
|
|
|
run_id=run_id, status=status, extra=extra,
|
|
|
|
|
|
)
|
|
|
|
|
|
run = AutomationRun(
|
|
|
|
|
|
run_id=run_id, rule_id=rule.rule_id, room_id=rule.room_id,
|
|
|
|
|
|
trigger=dict(trigger), gear=gear_code, action=rule.action,
|
|
|
|
|
|
status=status, at=fmt_dt(now_dt), payload=payload, audit_id=audit_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.runs[run_id] = run
|
|
|
|
|
|
if save is not None:
|
|
|
|
|
|
save()
|
|
|
|
|
|
return run
|
|
|
|
|
|
|
|
|
|
|
|
def _audit_run(self, world: dict[str, Any], next_id: Callable[[str], int], *,
|
|
|
|
|
|
rule: Rule, trigger: dict[str, Any], gear: str, power: str,
|
|
|
|
|
|
run_id: str, status: str, extra: dict[str, Any]) -> int:
|
|
|
|
|
|
event = write_audit(
|
|
|
|
|
|
world, next_id, actor=self.actor, category="AUTOMATION", action="automation.run",
|
|
|
|
|
|
target={"type": "RULE", "id": rule.rule_id, "roomId": rule.room_id},
|
|
|
|
|
|
power=power,
|
|
|
|
|
|
rationale={
|
|
|
|
|
|
"gear": gear,
|
|
|
|
|
|
"rule": {"id": rule.rule_id, "roomId": rule.room_id, "action": rule.action},
|
|
|
|
|
|
"trigger": dict(trigger),
|
|
|
|
|
|
"runId": run_id,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
**extra,
|
|
|
|
|
|
},
|
|
|
|
|
|
result="SUCCESS" if status not in ("DENIED", "FAILED") else status,
|
|
|
|
|
|
)
|
|
|
|
|
|
return int(event["id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 轻量调度器 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
def _iso(dt: datetime) -> str:
|
|
|
|
|
|
"""完整精度 ISO 时间(秒级 + 时区),供调度状态落盘(fmt_dt 仅分钟精度)。"""
|
|
|
|
|
|
if dt.tzinfo is None:
|
|
|
|
|
|
dt = dt.replace(tzinfo=UTC)
|
|
|
|
|
|
return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_iso(text: str) -> datetime:
|
|
|
|
|
|
"""宽容解析 _iso 输出;非法输入抛 ValueError。"""
|
|
|
|
|
|
return datetime.strptime(str(text), "%Y-%m-%dT%H:%M:%S%z")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AutomationScheduler:
|
|
|
|
|
|
"""轻量调度器:间隔/定时触发;整体或按规则暂停/恢复;支持回放。
|
|
|
|
|
|
|
|
|
|
|
|
tick() 为拉模式时钟推进(宿主周期性调用);dispatch_event/observe_metrics
|
|
|
|
|
|
分别处理外部事件与阈值观测。定时规则首个 tick 仅建立基线(不立即触发),
|
|
|
|
|
|
之后按 cron/间隔到期触发;暂停期间不触发任何规则。
|
|
|
|
|
|
|
|
|
|
|
|
持久化(round-40 方向 S · 矩阵 76):snapshot_state/save_state 落盘
|
|
|
|
|
|
规则与调度状态(next_run 基线、暂停、运行记录),load_state 宽容恢复。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, executor: RuleExecutor, *,
|
|
|
|
|
|
now: Callable[[], datetime] | None = None) -> None:
|
|
|
|
|
|
self.executor = executor
|
|
|
|
|
|
self._now = now if now is not None else (lambda: datetime.now(UTC))
|
|
|
|
|
|
self._paused = False
|
|
|
|
|
|
self._rule_paused: set[str] = set()
|
|
|
|
|
|
self._last_run: dict[str, datetime] = {}
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def paused(self) -> bool:
|
|
|
|
|
|
return self._paused
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def runs(self) -> dict[str, AutomationRun]:
|
|
|
|
|
|
return self.executor.runs
|
|
|
|
|
|
|
|
|
|
|
|
def pause(self) -> None:
|
|
|
|
|
|
self._paused = True
|
|
|
|
|
|
|
|
|
|
|
|
def resume(self) -> None:
|
|
|
|
|
|
self._paused = False
|
|
|
|
|
|
|
|
|
|
|
|
def pause_rule(self, rule_id: str) -> None:
|
|
|
|
|
|
self._rule_paused.add(rule_id)
|
|
|
|
|
|
|
|
|
|
|
|
def resume_rule(self, rule_id: str) -> None:
|
|
|
|
|
|
self._rule_paused.discard(rule_id)
|
|
|
|
|
|
|
|
|
|
|
|
def paused_rules(self) -> list[str]:
|
|
|
|
|
|
return sorted(self._rule_paused)
|
|
|
|
|
|
|
|
|
|
|
|
def snapshot_state(self) -> dict[str, Any]:
|
|
|
|
|
|
"""序列化调度状态(规则 + 到期基线 + 暂停 + 运行记录),供落盘持久化。"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"version": 1,
|
|
|
|
|
|
"savedAt": _iso(self._now()),
|
|
|
|
|
|
"paused": self._paused,
|
|
|
|
|
|
"rulePaused": sorted(self._rule_paused),
|
|
|
|
|
|
"lastRun": {rule_id: _iso(dt) for rule_id, dt in self._last_run.items()},
|
|
|
|
|
|
"runSeq": self.executor._run_seq,
|
|
|
|
|
|
"rules": [rule.as_dict() for rule in self.executor.registry.list()],
|
|
|
|
|
|
"runs": [run.as_dict() for run in self.executor.runs.values()],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def restore_state(self, state: dict[str, Any]) -> int:
|
|
|
|
|
|
"""宽容恢复调度状态:坏项/缺失键跳过不阻断;返回恢复的规则数。
|
|
|
|
|
|
|
|
|
|
|
|
已登记的同名规则不覆盖(保留运行期预置规则);lastRun 基线恢复后
|
|
|
|
|
|
间隔/cron 到期判断可跨重启连续(重启不重复触发已跑过的定时规则)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(state, dict):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
self._paused = bool(state.get("paused", False))
|
|
|
|
|
|
raw_paused = state.get("rulePaused") or []
|
|
|
|
|
|
self._rule_paused = {str(rid) for rid in raw_paused if isinstance(rid, str)}
|
|
|
|
|
|
raw_last = state.get("lastRun") or {}
|
|
|
|
|
|
if isinstance(raw_last, dict):
|
|
|
|
|
|
for rule_id, iso_text in raw_last.items():
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._last_run[str(rule_id)] = _parse_iso(str(iso_text))
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
continue # 宽容:坏基线跳过
|
|
|
|
|
|
seq = state.get("runSeq")
|
|
|
|
|
|
if isinstance(seq, int) and seq > self.executor._run_seq:
|
|
|
|
|
|
self.executor._run_seq = seq
|
|
|
|
|
|
existing = {rule.rule_id for rule in self.executor.registry.list()}
|
|
|
|
|
|
restored = 0
|
|
|
|
|
|
for raw in state.get("rules") or []:
|
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
rule = Rule.from_dict(raw)
|
|
|
|
|
|
except (KeyError, TypeError, ValueError):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if rule.rule_id in existing:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.executor.registry.register(rule)
|
|
|
|
|
|
restored += 1
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for raw in state.get("runs") or []:
|
|
|
|
|
|
if not isinstance(raw, dict) or "runId" not in raw:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
run = AutomationRun.from_dict(raw)
|
|
|
|
|
|
self.executor.runs[run.run_id] = run
|
|
|
|
|
|
except (KeyError, TypeError, ValueError):
|
|
|
|
|
|
continue
|
|
|
|
|
|
return restored
|
|
|
|
|
|
|
|
|
|
|
|
def save_state(self, path: str | os.PathLike[str]) -> None:
|
|
|
|
|
|
"""原子落盘调度状态(临时文件 + os.replace,避免半截文件)。"""
|
|
|
|
|
|
target = os.fspath(path)
|
|
|
|
|
|
tmp = f"{target}.tmp"
|
|
|
|
|
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
|
|
|
|
json.dump(self.snapshot_state(), fh, ensure_ascii=False, indent=2)
|
|
|
|
|
|
os.replace(tmp, target)
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def load_state(cls, path: str | os.PathLike[str], *, executor: RuleExecutor | None = None,
|
|
|
|
|
|
now: Callable[[], datetime] | None = None) -> AutomationScheduler:
|
|
|
|
|
|
"""从磁盘宽容加载调度状态;缺文件/损坏 JSON → 空调度(不抛错)。"""
|
|
|
|
|
|
scheduler = cls(executor if executor is not None else RuleExecutor(), now=now)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(os.fspath(path), "r", encoding="utf-8") as fh:
|
|
|
|
|
|
state = json.load(fh)
|
|
|
|
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError):
|
|
|
|
|
|
return scheduler
|
|
|
|
|
|
scheduler.restore_state(state)
|
|
|
|
|
|
return scheduler
|
|
|
|
|
|
|
|
|
|
|
|
def due_rules(self, now: datetime | None = None) -> list[Rule]:
|
|
|
|
|
|
"""返回当前到期的定时/间隔规则(供宿主查看,不执行)。"""
|
|
|
|
|
|
moment = now if now is not None else self._now()
|
|
|
|
|
|
if self._paused:
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [
|
|
|
|
|
|
rule for rule in self.executor.registry.list(enabled=True)
|
|
|
|
|
|
if rule.rule_id not in self._rule_paused
|
|
|
|
|
|
and rule.trigger.get("on") == "schedule"
|
|
|
|
|
|
and self._due(rule.rule_id, rule.trigger, moment)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def tick(self, *, world: dict[str, Any], next_id: Callable[[str], int],
|
|
|
|
|
|
session_id: str = "automation", save: Callable[[], None] | None = None,
|
|
|
|
|
|
now: datetime | None = None) -> list[AutomationRun]:
|
|
|
|
|
|
"""推进一次时钟:执行所有到期的定时/间隔规则并写审计。"""
|
|
|
|
|
|
moment = now if now is not None else self._now()
|
|
|
|
|
|
if self._paused:
|
|
|
|
|
|
return []
|
|
|
|
|
|
runs: list[AutomationRun] = []
|
|
|
|
|
|
for rule in self.executor.registry.list(enabled=True):
|
|
|
|
|
|
if rule.rule_id in self._rule_paused:
|
|
|
|
|
|
continue
|
|
|
|
|
|
trigger = rule.trigger
|
|
|
|
|
|
if trigger.get("on") != "schedule":
|
|
|
|
|
|
continue
|
|
|
|
|
|
if rule.rule_id not in self._last_run and "cron" in trigger:
|
|
|
|
|
|
self._last_run[rule.rule_id] = moment
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not self._due(rule.rule_id, trigger, moment):
|
|
|
|
|
|
continue
|
|
|
|
|
|
self._last_run[rule.rule_id] = moment
|
|
|
|
|
|
runs.append(self.executor.execute(rule, trigger, world=world, next_id=next_id,
|
|
|
|
|
|
session_id=session_id, save=save, now=moment))
|
|
|
|
|
|
return runs
|
|
|
|
|
|
|
|
|
|
|
|
def dispatch_event(self, event: dict[str, Any], *, world: dict[str, Any],
|
|
|
|
|
|
next_id: Callable[[str], int], session_id: str = "automation",
|
|
|
|
|
|
save: Callable[[], None] | None = None,
|
|
|
|
|
|
now: datetime | None = None) -> list[AutomationRun]:
|
|
|
|
|
|
"""外部事件分发:命中事件规则的房间自动化立即触发。"""
|
|
|
|
|
|
if self._paused:
|
|
|
|
|
|
return []
|
|
|
|
|
|
runs: list[AutomationRun] = []
|
|
|
|
|
|
for rule in self.executor.registry.list(enabled=True):
|
|
|
|
|
|
if rule.rule_id in self._rule_paused or not matches_event(rule.trigger, event):
|
|
|
|
|
|
continue
|
|
|
|
|
|
runs.append(self.executor.execute(rule, rule.trigger, world=world, next_id=next_id,
|
|
|
|
|
|
session_id=session_id, save=save, now=now))
|
|
|
|
|
|
return runs
|
|
|
|
|
|
|
|
|
|
|
|
def observe_metrics(self, metrics: dict[str, float], *, world: dict[str, Any],
|
|
|
|
|
|
next_id: Callable[[str], int], session_id: str = "automation",
|
|
|
|
|
|
save: Callable[[], None] | None = None,
|
|
|
|
|
|
now: datetime | None = None) -> list[AutomationRun]:
|
|
|
|
|
|
"""阈值观测:满足阈值条件的房间自动化立即触发。"""
|
|
|
|
|
|
if self._paused:
|
|
|
|
|
|
return []
|
|
|
|
|
|
runs: list[AutomationRun] = []
|
|
|
|
|
|
for rule in self.executor.registry.list(enabled=True):
|
|
|
|
|
|
if rule.rule_id in self._rule_paused or not matches_threshold(rule.trigger, metrics):
|
|
|
|
|
|
continue
|
|
|
|
|
|
runs.append(self.executor.execute(rule, rule.trigger, world=world, next_id=next_id,
|
|
|
|
|
|
session_id=session_id, save=save, now=now))
|
|
|
|
|
|
return runs
|
|
|
|
|
|
|
|
|
|
|
|
def replay(self, run_id: str, *, world: dict[str, Any], next_id: Callable[[str], int],
|
|
|
|
|
|
session_id: str = "automation", save: Callable[[], None] | None = None,
|
|
|
|
|
|
now: datetime | None = None) -> AutomationRun:
|
|
|
|
|
|
"""回放一次运行:以相同规则/触发条件重新执行并写 automation.replay 审计。"""
|
|
|
|
|
|
previous = self.executor.runs.get(run_id)
|
|
|
|
|
|
if previous is None:
|
|
|
|
|
|
raise KeyError(f"运行 {run_id} 不存在,无法回放")
|
|
|
|
|
|
rule = self.executor.registry.get(previous.rule_id)
|
|
|
|
|
|
rerun = self.executor.execute(rule, dict(previous.trigger), world=world, next_id=next_id,
|
|
|
|
|
|
session_id=session_id, save=save, now=now)
|
|
|
|
|
|
rerun.payload["replayedFrom"] = run_id
|
|
|
|
|
|
write_audit(
|
|
|
|
|
|
world, next_id, actor=self.executor.actor, category="AUTOMATION",
|
|
|
|
|
|
action="automation.replay",
|
|
|
|
|
|
target={"type": "RULE", "id": rule.rule_id, "roomId": rule.room_id},
|
|
|
|
|
|
power=action_power(rule.action),
|
|
|
|
|
|
rationale={
|
|
|
|
|
|
"sourceRunId": run_id,
|
|
|
|
|
|
"newRunId": rerun.run_id,
|
|
|
|
|
|
"gear": rerun.gear,
|
|
|
|
|
|
"rule": {"id": rule.rule_id, "roomId": rule.room_id, "action": rule.action},
|
|
|
|
|
|
"trigger": dict(previous.trigger),
|
|
|
|
|
|
},
|
|
|
|
|
|
result="SUCCESS",
|
|
|
|
|
|
)
|
|
|
|
|
|
if save is not None:
|
|
|
|
|
|
save()
|
|
|
|
|
|
return rerun
|
|
|
|
|
|
|
|
|
|
|
|
def _due(self, rule_id: str, trigger: dict[str, Any], moment: datetime) -> bool:
|
|
|
|
|
|
last = self._last_run.get(rule_id)
|
|
|
|
|
|
every = trigger.get("every")
|
|
|
|
|
|
if every is not None:
|
|
|
|
|
|
if last is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return moment - last >= timedelta(seconds=float(every))
|
|
|
|
|
|
cron = parse_cron(str(trigger["cron"]))
|
|
|
|
|
|
if last is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
nxt = cron_next(cron, last)
|
|
|
|
|
|
return nxt is not None and nxt <= moment
|