aps-agent/tests/golden/test_automation_gears.py

511 lines
22 KiB
Python
Raw Normal View History

# ============================================================
# 房间内自动化 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"}