aps-agent/tests/golden/test_automation_gears.py

511 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 房间内自动化 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,
BusinessActionBinding,
EscalationGrant,
GateRequiredError,
Gear,
Rule,
RuleExecutor,
RuleRegistry,
automation_authorization,
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",
authorization=automation_authorization(g4, executor.binding_for(g4.action)),
)
decision = _approve_escalation_twice(gate, card["confirmId"])
gate.grant_from_decision(card["confirmId"], decision, rule_id="r-g4", params=card["params"])
def bridge(_binding, params, _ctx):
calls.append(("commit", params))
return {"published": True}
executor.business_bridge = bridge
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"]
@pytest.mark.parametrize(
"mutation",
["params", "action", "trigger", "guardrails", "gear", "room", "binding"],
)
def test_g4_grant_is_revoked_when_authorized_payload_drifts(mutation: str):
store = _MemStore()
gate = AutomationGate()
executor, calls = _make_executor(gate=gate)
executor.business_bridge = lambda _binding, params, _ctx: calls.append(("bridge", params)) or {"ok": True}
rule = Rule(
"r-g4-bound",
"room-1",
{"on": "threshold", "metric": "risk", "op": ">", "value": 5},
gear="G4",
action="commit",
params={"track": "fixed"},
)
authorization = automation_authorization(rule, executor.binding_for(rule.action))
card = gate.request(
"s", rule.rule_id, "G4", room_id=rule.room_id, authorization=authorization,
)
decision = _approve_escalation_twice(gate, card["confirmId"])
grant = gate.grant_from_decision(
card["confirmId"], decision, rule_id=rule.rule_id, params=card["params"],
)
assert grant.authorization_digest
if mutation == "params":
rule.params["track"] = "flex"
elif mutation == "action":
rule.action = "reschedule"
elif mutation == "trigger":
rule.trigger["value"] = 9
elif mutation == "guardrails":
rule.guardrails.append("new-guardrail")
elif mutation == "gear":
rule.gear = "G3"
elif mutation == "room":
rule.room_id = "room-2"
else:
executor.register_binding(BusinessActionBinding(
"commit", "flex.reschedule", "P2", {"level": "L4"}, "changed binding",
))
run = executor.execute(
rule,
rule.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.payload["reason"] == "G4 升权授权载荷已漂移,旧凭据已撤销"
assert gate.grant_for(rule.rule_id) is None
assert calls == []
def test_g4_grant_rejects_rule_id_substitution_before_consuming_grant():
gate = AutomationGate()
executor, _calls = _make_executor(gate=gate)
rule = Rule(
"r-g4-scope",
"room-1",
{"on": "schedule", "every": 60},
gear="G4",
action="commit",
)
card = gate.request(
"s",
rule.rule_id,
"G4",
room_id=rule.room_id,
authorization=automation_authorization(rule, executor.binding_for(rule.action)),
)
decision = _approve_escalation_twice(gate, card["confirmId"])
with pytest.raises(GateRequiredError, match="规则标识"):
gate.grant_from_decision(
card["confirmId"], decision, rule_id="another-rule", params=card["params"],
)
grant = gate.grant_from_decision(
card["confirmId"], decision, rule_id=rule.rule_id, params=card["params"],
)
assert grant.rule_id == rule.rule_id
with pytest.raises(GateRequiredError, match="无效或已被使用"):
gate.grant_from_decision(
card["confirmId"], decision, rule_id=rule.rule_id, params=card["params"],
)
def test_g4_request_rejects_partial_authorization_snapshot():
gate = AutomationGate()
with pytest.raises(ValueError, match="字段不完整"):
gate.request(
"s",
"r-g4-partial",
"G4",
room_id="room-1",
authorization={
"rule": {
"ruleId": "r-g4-partial",
"roomId": "room-1",
"gear": "G4",
}
},
)
# ---------------- 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"}