aps-agent/server/agent_core/automation.py

1226 lines
52 KiB
Python
Raw Normal View History

# ============================================================
# 房间内自动化 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 copy
import hashlib
import hmac
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)"),
}
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
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
authorization_digest: str = ""
authorization: dict[str, Any] = field(default_factory=dict)
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,
"authorizationDigest": self.authorization_digest,
"authorization": copy.deepcopy(self.authorization),
}
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,
authorization: dict[str, Any] | None = None) -> dict[str, Any]:
"""出升权确认卡(P3,双人确认;动作此刻并未执行)。"""
gear = gear_of(target_gear)
if gear.level <= G2.level:
raise ValueError(f"{gear.code} 无需升权门禁,仅 G3/G4 需要")
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 ""
)
params: dict[str, Any] = {
"ruleId": rule_id,
"roomId": room_id,
"gear": gear.code,
"reason": reason,
"authorization": authorization_snapshot,
"authorizationDigest": authorization_digest,
}
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 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
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,
authorization_digest=authorization_digest,
authorization=authorization_snapshot,
)
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
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},
)
grant = self.gate.grant_for(rule.rule_id, now_dt.timestamp())
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
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": (
"G4 升权授权载荷已漂移,旧凭据已撤销"
if authorization_drift
else "档位升权未获门禁授权(G3/G4 必须经 harness 确认)"
)
},
extra={"gateDenied": True, "authorizationDrift": authorization_drift},
)
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",
"authorizationDigest": grant.authorization_digest,
}
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:
authorization = automation_authorization(rule, self.binding_for(rule.action))
card = self.gate.request(
session_id, rule.rule_id, rule.gear, room_id=rule.room_id,
reason=rule.description or "规则触发升权",
authorization=authorization,
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