2026-08-11 00:54:05 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 房间内自动化 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,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
BusinessActionBinding,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
EscalationGrant,
|
|
|
|
|
|
GateRequiredError,
|
|
|
|
|
|
Gear,
|
|
|
|
|
|
Rule,
|
|
|
|
|
|
RuleExecutor,
|
|
|
|
|
|
RuleRegistry,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
automation_authorization,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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,门禁授权 + 全审计)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
card = gate.request(
|
|
|
|
|
|
"s",
|
|
|
|
|
|
"r-g4",
|
|
|
|
|
|
"G4",
|
|
|
|
|
|
room_id="room-1",
|
|
|
|
|
|
authorization=automation_authorization(g4, executor.binding_for(g4.action)),
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
decision = _approve_escalation_twice(gate, card["confirmId"])
|
|
|
|
|
|
gate.grant_from_decision(card["confirmId"], decision, rule_id="r-g4", params=card["params"])
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def bridge(_binding, params, _ctx):
|
|
|
|
|
|
calls.append(("commit", params))
|
|
|
|
|
|
return {"published": True}
|
|
|
|
|
|
executor.business_bridge = bridge
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
@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",
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# ---------------- 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"}
|