390 lines
18 KiB
Python
390 lines
18 KiB
Python
# ============================================================
|
||
# 房间内自动化 G0-G4 档位黄金测试(plan.md §5.3 / §6.5)
|
||
# 覆盖:G0-G4 档位状态机、升权门禁、规则触发、暂停/回放/审计
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import pytest
|
||
|
||
from server.agent_core.automation import (
|
||
AutomationGate,
|
||
AutomationRun,
|
||
AutomationScheduler,
|
||
EscalationGrant,
|
||
GateRequiredError,
|
||
Gear,
|
||
Rule,
|
||
RuleExecutor,
|
||
RuleRegistry,
|
||
cron_next,
|
||
gear_of,
|
||
matches_event,
|
||
matches_threshold,
|
||
parse_cron,
|
||
)
|
||
from server.auth.context import IdentityContext, bind_identity, reset_identity
|
||
|
||
|
||
class _MemStore:
|
||
"""极简内存 store:world + next_id + save(黄金测试隔离)。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.data: dict = {"auditEvents": []}
|
||
self._seq = 0
|
||
|
||
def next_id(self, _kind: str) -> int:
|
||
self._seq += 1
|
||
return self._seq
|
||
|
||
def save(self) -> None:
|
||
pass
|
||
|
||
|
||
def _audit_events(store: _MemStore, action: str = "automation.run"):
|
||
return [event for event in store.data["auditEvents"] if event["action"] == action]
|
||
|
||
|
||
def _make_executor(registry=None, gate=None):
|
||
"""构造带处理器桩的 RuleExecutor,并返回调用记录(动作名, payload)。"""
|
||
calls: list[tuple[str, dict]] = []
|
||
|
||
def on_notify(payload):
|
||
calls.append(("notify", payload))
|
||
return {"sent": True}
|
||
|
||
def on_replan(payload):
|
||
calls.append(("re-plan", payload))
|
||
return {"draft": True}
|
||
|
||
def on_commit(payload):
|
||
calls.append(("commit", payload))
|
||
return {"published": True}
|
||
|
||
executor = RuleExecutor(
|
||
registry=registry,
|
||
gate=gate,
|
||
handlers={"notify": on_notify, "re-plan": on_replan, "commit": on_commit},
|
||
)
|
||
return executor, calls
|
||
|
||
|
||
def _approve_escalation_twice(gate: AutomationGate, confirm_id: str) -> dict:
|
||
"""P3 升权确认卡需两重审批且由不同用户完成(SOD),返回最终决策记录。"""
|
||
first = gate.approve(confirm_id)
|
||
assert first is not None
|
||
assert first["needsSecondConfirm"] is True
|
||
token = bind_identity(IdentityContext(
|
||
2002, "collaborator", "Collaborator", "platform", roles=("planner",),
|
||
))
|
||
try:
|
||
second = gate.approve(confirm_id)
|
||
finally:
|
||
reset_identity(token)
|
||
assert second is not None
|
||
assert second["needsSecondConfirm"] is False
|
||
return second
|
||
|
||
|
||
# ---------------- G1:G0-G4 档位状态机 ----------------
|
||
|
||
def test_gear_state_machine_levels_and_semantics():
|
||
"""G0-G4 档位等级、行为语义与封顶关系;非法档位拒绝。"""
|
||
codes = [gear_of(code).code for code in ("G0", "G1", "G2", "G3", "G4")]
|
||
assert codes == ["G0", "G1", "G2", "G3", "G4"]
|
||
assert [gear_of(code).level for code in codes] == [0, 1, 2, 3, 4]
|
||
# G0/G1:只读建议;G2:沙盒执行;G3:门禁;G4:受控自动
|
||
assert gear_of("G0").proposes and not gear_of("G0").sandbox
|
||
assert gear_of("G1").proposes and not gear_of("G1").sandbox
|
||
assert gear_of("G2").sandbox and not gear_of("G2").gated
|
||
assert gear_of("G3").gated and not gear_of("G3").auto
|
||
assert gear_of("G4").gated and gear_of("G4").auto
|
||
assert isinstance(gear_of("G4"), Gear)
|
||
# 档位偏序与封顶:任何放权不得超过规则允许档位
|
||
assert gear_of("G0") < gear_of("G2") < gear_of("G4")
|
||
assert gear_of("G4").cap("G2") == gear_of("G2")
|
||
assert gear_of("G1").cap("G4") == gear_of("G1")
|
||
with pytest.raises(ValueError):
|
||
gear_of("G9")
|
||
with pytest.raises(ValueError):
|
||
gear_of(9)
|
||
|
||
|
||
# ---------------- G2:升权门禁(G3/G4 必须经 harness) ----------------
|
||
|
||
def test_escalation_gate_requires_harness_confirmation():
|
||
"""升权必须走 harness 门禁:P3 双人确认后才颁发升权凭据。"""
|
||
gate = AutomationGate()
|
||
card = gate.request("session-e", "rule-g3", "G3", room_id="room-1", reason="夜间值守")
|
||
confirm_id = card["confirmId"]
|
||
assert card["power"] == "P3" # 未登记动作 → harness 按 fail-closed 默认 P3
|
||
assert gate.grant_for("rule-g3") is None
|
||
with pytest.raises(GateRequiredError):
|
||
gate.grant_from_decision(
|
||
confirm_id, {"executionGrant": "stale"}, rule_id="rule-g3", params=card["params"]
|
||
)
|
||
decision = _approve_escalation_twice(gate, confirm_id)
|
||
assert decision["approvalStep"] == 2
|
||
grant = gate.grant_from_decision(confirm_id, decision, rule_id="rule-g3", params=card["params"])
|
||
assert isinstance(grant, EscalationGrant)
|
||
assert grant.target_gear == gear_of("G3")
|
||
assert gate.grant_for("rule-g3") is not None
|
||
assert gate.grant_for("rule-g3").confirm_id == confirm_id
|
||
|
||
|
||
# ---------------- G3:规则触发(事件/阈值/定时)+ 审计字段 ----------------
|
||
|
||
def test_event_threshold_schedule_triggers_and_audit_fields():
|
||
"""三类触发匹配 + automation.run 审计必须携带 gear/rule/trigger。"""
|
||
store = _MemStore()
|
||
registry = RuleRegistry()
|
||
executor, calls = _make_executor(registry=registry)
|
||
event_rule = Rule("r-event", "room-1",
|
||
{"on": "event", "source": "WMS", "type": "shortage"},
|
||
gear="G2", action="notify")
|
||
threshold_rule = Rule("r-thr", "room-1",
|
||
{"on": "threshold", "metric": "risk", "op": ">", "value": 5},
|
||
gear="G2", action="re-plan")
|
||
schedule_rule = Rule("r-cron", "room-1", {"on": "schedule", "cron": "0 8 * * 1-5"},
|
||
gear="G1", action="suggest")
|
||
registry.register(event_rule)
|
||
registry.register(threshold_rule)
|
||
registry.register(schedule_rule)
|
||
|
||
assert matches_event(event_rule.trigger, {"source": "WMS", "type": "shortage"})
|
||
assert not matches_event(event_rule.trigger, {"source": "QMS", "type": "shortage"})
|
||
assert matches_threshold(threshold_rule.trigger, {"risk": 6})
|
||
assert not matches_threshold(threshold_rule.trigger, {"risk": 5})
|
||
assert not matches_threshold(threshold_rule.trigger, {"other": 9})
|
||
|
||
run = executor.execute(event_rule, event_rule.trigger, world=store.data,
|
||
next_id=store.next_id, save=store.save)
|
||
assert run.status == "EXECUTED" and run.gear == "G2"
|
||
run = executor.execute(threshold_rule, threshold_rule.trigger, world=store.data,
|
||
next_id=store.next_id, save=store.save)
|
||
assert run.status == "EXECUTED"
|
||
run = executor.execute(schedule_rule, schedule_rule.trigger, world=store.data,
|
||
next_id=store.next_id, save=store.save)
|
||
assert run.status == "PROPOSED" # G1 只提议,不执行
|
||
assert [name for name, _ in calls] == ["notify", "re-plan"]
|
||
|
||
events = _audit_events(store)
|
||
assert len(events) == 3
|
||
for event in events:
|
||
assert event["category"] == "AUTOMATION"
|
||
assert event["action"] == "automation.run"
|
||
assert event["rationale"]["gear"] in {"G1", "G2"}
|
||
assert event["rationale"]["rule"]["roomId"] == "room-1"
|
||
assert event["rationale"]["trigger"]["on"] in {"event", "threshold", "schedule"}
|
||
assert event["rationale"]["runId"]
|
||
|
||
# cron 解析与 next(定时触发匹配)
|
||
cron = parse_cron("*/15 8 * * 1-5")
|
||
moment = datetime(2026, 8, 3, 8, 15, tzinfo=UTC)
|
||
assert cron.matches(moment) == (moment.weekday() < 5)
|
||
assert cron_next("0 8 * * *", datetime(2026, 8, 3, 8, 0, tzinfo=UTC)) == datetime(
|
||
2026, 8, 4, 8, 0, tzinfo=UTC
|
||
)
|
||
assert cron_next("*/15 * * * *", datetime(2026, 8, 3, 8, 16, tzinfo=UTC)) == datetime(
|
||
2026, 8, 3, 8, 30, tzinfo=UTC
|
||
)
|
||
|
||
|
||
# ---------------- G4:档位矩阵(提议/沙盒/确认卡/受控自动) ----------------
|
||
|
||
def test_gear_matrix_propose_sandbox_gate_and_auto():
|
||
"""G0/G1 只提议;G2 沙盒执行且 P2 只出卡;G3/G4 未升权封顶/拒绝;G4 有凭据才自动。"""
|
||
store = _MemStore()
|
||
registry = RuleRegistry()
|
||
gate = AutomationGate()
|
||
executor, calls = _make_executor(registry=registry, gate=gate)
|
||
|
||
g0 = Rule("r-g0", "room-1", {"on": "event", "source": "WMS", "type": "shortage"},
|
||
gear="G0", action="re-plan")
|
||
registry.register(g0)
|
||
run = executor.execute(g0, g0.trigger, world=store.data, next_id=store.next_id,
|
||
session_id="s", save=store.save)
|
||
assert run.status == "PROPOSED" and run.gear == "G0"
|
||
assert calls == []
|
||
|
||
g2 = Rule("r-g2", "room-1", {"on": "threshold", "metric": "risk", "op": ">", "value": 5},
|
||
gear="G2", action="commit")
|
||
registry.register(g2)
|
||
run = executor.execute(g2, g2.trigger, world=store.data, next_id=store.next_id,
|
||
session_id="s", save=store.save)
|
||
assert run.status == "STAGED" # P2 只能出确认卡,绝不自动执行
|
||
assert run.payload["power"] == "P2"
|
||
assert calls == []
|
||
|
||
g3 = Rule("r-g3", "room-1", {"on": "threshold", "metric": "risk", "op": ">", "value": 5},
|
||
gear="G3", action="re-plan")
|
||
registry.register(g3)
|
||
run = executor.execute(g3, g3.trigger, world=store.data, next_id=store.next_id,
|
||
session_id="s", save=store.save)
|
||
assert run.status == "GATE_REQUIRED" # 未升权:封顶 G2 沙盒 + 出升权卡
|
||
assert run.gear == "G2"
|
||
assert run.payload["underlying"] == "EXECUTED"
|
||
assert run.payload["escalateConfirmId"]
|
||
|
||
g4 = Rule("r-g4", "room-1", {"on": "threshold", "metric": "risk", "op": ">", "value": 5},
|
||
gear="G4", action="commit")
|
||
registry.register(g4)
|
||
run = executor.execute(g4, g4.trigger, world=store.data, next_id=store.next_id,
|
||
session_id="s", save=store.save, escalate_on_demand=False)
|
||
assert run.status == "DENIED" # 无凭据且不出卡 → 拒绝执行
|
||
assert run.gear == "G4"
|
||
|
||
# 升权 r-g3 → G3:凭据到手后按 G3 直接执行(P1 re-plan)
|
||
card = gate.request("s", "r-g3", "G3", room_id="room-1")
|
||
decision = _approve_escalation_twice(gate, card["confirmId"])
|
||
gate.grant_from_decision(card["confirmId"], decision, rule_id="r-g3", params=card["params"])
|
||
run = executor.execute(g3, g3.trigger, world=store.data, next_id=store.next_id,
|
||
session_id="s", save=store.save)
|
||
assert run.status == "EXECUTED" and run.gear == "G3"
|
||
|
||
# 升权 r-g4 → G4:受控自动执行 commit(P2,门禁授权 + 全审计)
|
||
card = gate.request("s", "r-g4", "G4", room_id="room-1")
|
||
decision = _approve_escalation_twice(gate, card["confirmId"])
|
||
gate.grant_from_decision(card["confirmId"], decision, rule_id="r-g4", params=card["params"])
|
||
run = executor.execute(g4, g4.trigger, world=store.data, next_id=store.next_id,
|
||
session_id="s", save=store.save)
|
||
assert run.status == "EXECUTED" and run.gear == "G4"
|
||
assert [name for name, _ in calls] == ["re-plan", "re-plan", "commit"]
|
||
|
||
|
||
# ---------------- G5:调度器 暂停/恢复/回放/审计 ----------------
|
||
|
||
def test_scheduler_interval_pause_resume_replay_and_audit():
|
||
"""间隔触发 + 整体/单规则暂停恢复 + 回放 + automation.replay 审计。"""
|
||
base = datetime(2026, 8, 2, 0, 0, 0, tzinfo=UTC)
|
||
clock = {"now": base}
|
||
store = _MemStore()
|
||
registry = RuleRegistry()
|
||
executor, calls = _make_executor(registry=registry)
|
||
registry.register(Rule("r-every", "room-1", {"on": "schedule", "every": 60},
|
||
gear="G2", action="notify"))
|
||
scheduler = AutomationScheduler(executor, now=lambda: clock["now"])
|
||
|
||
first = scheduler.tick(world=store.data, next_id=store.next_id, save=store.save)
|
||
assert len(first) == 1
|
||
assert isinstance(first[0], AutomationRun)
|
||
assert first[0].status == "EXECUTED"
|
||
|
||
scheduler.pause()
|
||
assert scheduler.tick(world=store.data, next_id=store.next_id, save=store.save) == []
|
||
assert scheduler.dispatch_event({"source": "WMS", "type": "shortage"},
|
||
world=store.data, next_id=store.next_id) == []
|
||
assert scheduler.observe_metrics({"risk": 9}, world=store.data,
|
||
next_id=store.next_id) == []
|
||
scheduler.resume()
|
||
|
||
clock["now"] = base + timedelta(seconds=61)
|
||
second = scheduler.tick(world=store.data, next_id=store.next_id, save=store.save)
|
||
assert len(second) == 1
|
||
|
||
scheduler.pause_rule("r-every")
|
||
clock["now"] = base + timedelta(seconds=122)
|
||
assert scheduler.tick(world=store.data, next_id=store.next_id, save=store.save) == []
|
||
assert scheduler.paused_rules() == ["r-every"]
|
||
scheduler.resume_rule("r-every")
|
||
|
||
replayed = scheduler.replay(first[0].run_id, world=store.data, next_id=store.next_id,
|
||
save=store.save)
|
||
assert replayed.run_id != first[0].run_id
|
||
assert replayed.payload["replayedFrom"] == first[0].run_id
|
||
|
||
run_events = _audit_events(store)
|
||
replay_events = _audit_events(store, "automation.replay")
|
||
assert len(run_events) == 3 # 两次 tick + 一次回放重执行
|
||
assert len(replay_events) == 1
|
||
assert replay_events[0]["rationale"]["sourceRunId"] == first[0].run_id
|
||
assert all(event["rationale"]["gear"] == "G2" for event in run_events)
|
||
assert all(event["rationale"]["trigger"]["on"] == "schedule" for event in run_events)
|
||
assert len(calls) == 3
|
||
with pytest.raises(KeyError):
|
||
scheduler.replay("no-such-run", world=store.data, next_id=store.next_id)
|
||
|
||
|
||
# ---------------- G6:调度器 cron 到期与阈值观测 ----------------
|
||
|
||
def test_scheduler_cron_due_and_threshold_observation():
|
||
"""cron 定时到期触发(首个 tick 仅建基线)+ 阈值观测触发。"""
|
||
clock = {"now": datetime(2026, 8, 3, 7, 0, 0, tzinfo=UTC)}
|
||
store = _MemStore()
|
||
registry = RuleRegistry()
|
||
executor, calls = _make_executor(registry=registry)
|
||
registry.register(Rule("r-cron", "room-1", {"on": "schedule", "cron": "0 8 * * *"},
|
||
gear="G2", action="notify"))
|
||
registry.register(Rule("r-thr", "room-1", {"on": "threshold", "metric": "utilization",
|
||
"op": ">", "value": 0.9},
|
||
gear="G2", action="re-plan"))
|
||
scheduler = AutomationScheduler(executor, now=lambda: clock["now"])
|
||
|
||
assert scheduler.tick(world=store.data, next_id=store.next_id, save=store.save) == []
|
||
clock["now"] = datetime(2026, 8, 3, 8, 0, 0, tzinfo=UTC)
|
||
runs = scheduler.tick(world=store.data, next_id=store.next_id, save=store.save)
|
||
assert [run.rule_id for run in runs] == ["r-cron"]
|
||
assert scheduler.tick(world=store.data, next_id=store.next_id, save=store.save) == []
|
||
|
||
observed = scheduler.observe_metrics({"utilization": 0.95}, world=store.data,
|
||
next_id=store.next_id, save=store.save)
|
||
assert [run.rule_id for run in observed] == ["r-thr"]
|
||
assert scheduler.observe_metrics({"utilization": 0.8}, world=store.data,
|
||
next_id=store.next_id, save=store.save) == []
|
||
assert len(calls) == 2
|
||
|
||
|
||
# ---------------- G7:调度器持久化(round-40 方向 S · 矩阵 76) ----------------
|
||
|
||
def test_scheduler_state_persistence_roundtrip(tmp_path):
|
||
"""规则/暂停/lastRun 到期基线/运行记录落盘后,重启宽容恢复且到期连续。"""
|
||
base = datetime(2026, 8, 2, 0, 0, 0, tzinfo=UTC)
|
||
clock = {"now": base}
|
||
store = _MemStore()
|
||
registry = RuleRegistry()
|
||
executor, _calls = _make_executor(registry=registry)
|
||
registry.register(Rule("r-every", "room-1", {"on": "schedule", "every": 60},
|
||
gear="G2", action="notify"))
|
||
registry.register(Rule("r-paused", "room-1", {"on": "schedule", "every": 120},
|
||
gear="G2", action="notify"))
|
||
scheduler = AutomationScheduler(executor, now=lambda: clock["now"])
|
||
scheduler.pause_rule("r-paused")
|
||
assert [r.rule_id for r in scheduler.tick(world=store.data, next_id=store.next_id,
|
||
save=store.save)] == ["r-every"]
|
||
|
||
state_path = tmp_path / "automation_state.json"
|
||
scheduler.save_state(state_path)
|
||
assert state_path.exists()
|
||
|
||
clock["now"] = base + timedelta(seconds=61)
|
||
loaded = AutomationScheduler.load_state(state_path)
|
||
# 规则/暂停/运行记录恢复
|
||
assert [r.rule_id for r in loaded.executor.registry.list()] == ["r-every", "r-paused"]
|
||
assert loaded.paused_rules() == ["r-paused"]
|
||
assert any(r.rule_id == "r-every" for r in loaded.executor.runs.values())
|
||
# lastRun 基线恢复:+61s 后 r-every 到期再触发;r-paused 仍暂停
|
||
runs = loaded.tick(world=store.data, next_id=store.next_id, save=store.save,
|
||
now=clock["now"])
|
||
assert [r.rule_id for r in runs] == ["r-every"]
|
||
# 加载后的执行器无业务处理器(run 会 FAILED),但到期触发本身成立:
|
||
# lastRun 基线恢复 → 到期连续(不重复触发)
|
||
assert runs[0].rule_id == "r-every"
|
||
|
||
# 宽容加载:损坏/缺失文件 → 空调度不抛错
|
||
broken = tmp_path / "broken.json"
|
||
broken.write_text("{oops", encoding="utf-8")
|
||
assert AutomationScheduler.load_state(broken).executor.registry.list() == []
|
||
assert AutomationScheduler.load_state(tmp_path / "nope.json").executor.registry.list() == []
|
||
|
||
|
||
def test_rule_params_and_business_binding_registry():
|
||
"""动作注册表:commit → schedule.publish、reschedule → flex.reschedule;规则 params 随序列化往返。"""
|
||
from server.agent_core.automation import BUSINESS_ACTION_BINDINGS
|
||
assert BUSINESS_ACTION_BINDINGS["commit"].intent == "schedule.publish"
|
||
assert BUSINESS_ACTION_BINDINGS["reschedule"].intent == "flex.reschedule"
|
||
rule = Rule("r-l3", "room-1", {"on": "schedule", "every": 30}, gear="G2",
|
||
action="reschedule", params={"level": "L3"})
|
||
assert Rule.from_dict(rule.as_dict()) == rule
|
||
assert Rule.from_dict(rule.as_dict()).params == {"level": "L3"}
|