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