1047 lines
45 KiB
Python
1047 lines
45 KiB
Python
# ============================================================
|
||
# 房间内自动化 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
|
||
|
||
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)"),
|
||
}
|
||
|
||
|
||
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
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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,
|
||
summary_lines: list[str] | None = None) -> dict[str, Any]:
|
||
"""出升权确认卡(P3,双人确认;动作此刻并未执行)。"""
|
||
gear = gear_of(target_gear)
|
||
if gear.level <= G2.level:
|
||
raise ValueError(f"{gear.code} 无需升权门禁,仅 G3/G4 需要")
|
||
params: dict[str, Any] = {
|
||
"ruleId": rule_id,
|
||
"roomId": room_id,
|
||
"gear": gear.code,
|
||
"reason": reason,
|
||
}
|
||
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 需第二重审批)")
|
||
if not harness.consume_execution_grant(
|
||
grant_token, confirm_id=confirm_id, action=ESCALATION_ACTION, params=params
|
||
):
|
||
raise GateRequiredError("升权执行凭据无效或已被使用")
|
||
gear = gear_of(str(params.get("gear") or "G3"))
|
||
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,
|
||
)
|
||
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
|
||
grant = self.gate.grant_for(rule.rule_id, now_dt.timestamp())
|
||
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,
|
||
payload={"reason": "档位升权未获门禁授权(G3/G4 必须经 harness 确认)"},
|
||
extra={"gateDenied": True},
|
||
)
|
||
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",
|
||
}
|
||
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)
|
||
if rule.action in self.handlers:
|
||
return "EXECUTED", power, self._run_handler(rule, params, via_guard=False)
|
||
# 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:
|
||
card = self.gate.request(
|
||
session_id, rule.rule_id, rule.gear, room_id=rule.room_id,
|
||
reason=rule.description or "规则触发升权",
|
||
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
|
||
|
||
|