# ============================================================ # 治理规则域(moduleId: governance-rules, 可重生 ✅) # R71.6:约束/自动化规则 CRUD + 启停 + 审计轨迹 + 审计统计。 # 复用 server.agent_core.automation 的 Rule/RuleExecutor/AutomationScheduler # 与 server.agent_core.audit.write_audit;规则状态唯一事实源仍是 # AutomationScheduler(automation_state.json),审计走既有 auditEvents 链, # 不新增与 tick 冲突的规则状态副本。 # ============================================================ from __future__ import annotations import copy import re import uuid from collections import Counter from collections.abc import Callable, Sequence from typing import Any from server.agent_core.audit import write_audit from server.agent_core.automation import AutomationScheduler, Rule RULE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") GEAR_CODES = ("G0", "G1", "G2", "G3", "G4") ACTION_NAMES = ("notify", "suggest", "re-plan", "commit", "reschedule") TRIGGER_TYPES = ("event", "schedule", "threshold") class GovernanceRuleError(RuntimeError): """治理规则域错误(API 层映射为明确 4xx)。""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code def _time_tuple(value: Any) -> tuple[int, int, int, int, int] | None: """宽容解析 YYYY-MM-DD[ HH:MM](含 ISO T 分隔符)为比较用元组。""" text = str(value or "").strip() match = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2})(?::(\d{2}))?)?", text) if not match: return None year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) hour = int(match.group(4) or 0) minute = int(match.group(5) or 0) if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23 and 0 <= minute <= 59): return None return (year, month, day, hour, minute) def _bucket_tuple(value: tuple[int, int, int, int, int], granularity: str) -> tuple[int, ...]: if granularity == "hour": return value[:4] return value[:3] def _bucket_text(value: tuple[int, ...], granularity: str) -> str: if granularity == "hour": return f"{value[0]:04d}-{value[1]:02d}-{value[2]:02d} {value[3]:02d}" return f"{value[0]:04d}-{value[1]:02d}-{value[2]:02d}" def _event_bucket_key(event: dict[str, Any], granularity: str) -> tuple[int, ...] | None: parsed = _time_tuple(event.get("at") or event.get("ts")) return _bucket_tuple(parsed, granularity) if parsed is not None else None def _event_targets_rule(event: dict[str, Any], rule_id: str) -> bool: """审计事件是否作用于指定规则(target 或 rationale.rule)。""" try: target = event.get("target") or {} if str(target.get("type") or "") == "RULE" and str(target.get("id") or "") == rule_id: return True rationale = event.get("rationale") or {} rule_meta = rationale.get("rule") or {} return str(rule_meta.get("id") or "") == rule_id except (TypeError, AttributeError): return False def audit_stats( events: Sequence[dict[str, Any]], *, start_time: str | None = None, end_time: str | None = None, module: str | None = None, action: str | None = None, result: str | None = None, granularity: str = "day", ) -> dict[str, Any]: """审计量聚合:按模块(category)/动作/结果/时间窗口统计,供报表。""" if granularity not in ("day", "hour"): raise GovernanceRuleError( "INVALID_GRANULARITY", f"granularity 必须是 day 或 hour,收到 {granularity!r}", ) start_key = _time_tuple(start_time) if start_time else None if start_time and start_key is None: raise GovernanceRuleError("INVALID_TIME", f"startTime 无法解析: {start_time!r}") end_key = _time_tuple(end_time) if end_time else None if end_time and end_key is None: raise GovernanceRuleError("INVALID_TIME", f"endTime 无法解析: {end_time!r}") if start_key and end_key and _bucket_tuple(start_key, granularity) > _bucket_tuple(end_key, granularity): raise GovernanceRuleError("INVALID_TIME_WINDOW", "startTime 不能晚于 endTime") filtered: list[dict[str, Any]] = [] for event in events: if not isinstance(event, dict): continue if module is not None and str(event.get("category") or "") != module: continue if action is not None and str(event.get("action") or "") != action: continue if result is not None and str(event.get("result") or "") != result: continue bucket = _event_bucket_key(event, granularity) if start_key or end_key: if bucket is None: continue if start_key and bucket < _bucket_tuple(start_key, granularity): continue if end_key and bucket > _bucket_tuple(end_key, granularity): continue filtered.append(event) by_module = Counter(str(ev.get("category") or "UNKNOWN") for ev in filtered) by_action = Counter(str(ev.get("action") or "UNKNOWN") for ev in filtered) by_result = Counter(str(ev.get("result") or "UNKNOWN") for ev in filtered) trend: Counter[str] = Counter() rows: Counter[tuple[str, str, str]] = Counter() for event in filtered: bucket = _event_bucket_key(event, granularity) if bucket is not None: trend[_bucket_text(bucket, granularity)] += 1 rows[( str(event.get("category") or "UNKNOWN"), str(event.get("action") or "UNKNOWN"), str(event.get("result") or "UNKNOWN"), )] += 1 def _sorted(counter: Counter[str]) -> list[dict[str, Any]]: return [ {"name": key, "count": count} for key, count in sorted(counter.items(), key=lambda item: (-item[1], item[0])) ] return { "ok": True, "total": len(events), "filtered": len(filtered), "window": { "startTime": start_time, "endTime": end_time, "granularity": granularity, }, "byModule": [ {"module": item["name"], "count": item["count"]} for item in _sorted(by_module) ], "byAction": [ {"action": item["name"], "count": item["count"]} for item in _sorted(by_action) ], "byResult": [ {"result": item["name"], "count": item["count"]} for item in _sorted(by_result) ], "trend": [ {"bucket": bucket, "count": count} for bucket, count in sorted(trend.items(), key=lambda item: item[0]) ], "rows": [ {"module": module_key, "action": action_key, "result": result_key, "count": count} for (module_key, action_key, result_key), count in sorted( rows.items(), key=lambda item: (-item[1], item[0]), ) ], } class GovernanceRuleService: """规则管理服务:复用现有 AutomationScheduler 单例,变更全程审计。""" def __init__( self, *, runtime_provider: Callable[[], Any] | None = None, store_provider: Callable[[], Any] | None = None, scheduler: AutomationScheduler | None = None, state_path: str | None = None, ) -> None: self._runtime_provider = runtime_provider self._store_provider = store_provider self._scheduler_instance = scheduler self._state_path = state_path # ---------------- 基础设施接入 ---------------- def _runtime(self) -> Any: if self._runtime_provider is not None: return self._runtime_provider() from server.gateway.app import _get_automation_runtime return _get_automation_runtime() def _store(self) -> Any: if self._store_provider is not None: return self._store_provider() from server.state.store import get_store return get_store() def _scheduler(self) -> AutomationScheduler: if self._scheduler_instance is not None: return self._scheduler_instance return self._runtime().scheduler def _state_path(self) -> str: if self._state_path: return self._state_path return self._runtime().state_path def _state_file(self) -> str: return str(self._state_path or self._runtime().state_path) def _persist(self, store: Any, scheduler: AutomationScheduler) -> None: """落盘世界审计和调度状态;任一失败均显式关闭。""" try: store.save() scheduler.save_state(self._state_file()) except Exception as exc: raise GovernanceRuleError( "PERSISTENCE_FAILED", f"治理规则持久化失败: {exc}", ) from exc @staticmethod def _authenticated_actor(requested_actor: str = "") -> str: from server.auth.context import get_identity identity = get_identity(required=True) return identity.username or str(identity.user_id) # ---------------- 审计辅助 ---------------- def _audit_change( self, store: Any, *, actor: str, action: str, rule: Rule, rationale: dict[str, Any] | None = None, result: str = "SUCCESS", before_snapshot: str | None = None, evidence_refs: Sequence[str] = (), ) -> dict[str, Any]: return write_audit( store.data, store.next_id, actor=self._authenticated_actor(actor), category="GOVERNANCE", action=action, target={"type": "RULE", "id": rule.rule_id, "roomId": rule.room_id}, power="P2", rationale=rationale or {}, result=result, before_snapshot=before_snapshot, evidence_refs=evidence_refs, ) def _audit_failure( self, store: Any, scheduler: AutomationScheduler, *, actor: str, action: str, rule_id: str, payload: dict[str, Any] | None, code: str, message: str, ) -> None: world_before = copy.deepcopy(store.data) try: self._audit_change( store, actor=actor, action=action, rule=Rule( rule_id=rule_id or "?", room_id=str((payload or {}).get("roomId") or ""), trigger={}, ), rationale={"code": code, "error": message, "payload": payload or {}}, result="DENIED", ) self._persist(store, scheduler) except Exception: store.data.clear() store.data.update(world_before) raise def _rollback_change( self, store: Any, scheduler: AutomationScheduler, *, rule_id: str, previous_rule: Rule | None, was_paused: bool, world_before: dict[str, Any], ) -> list[str]: errors: list[str] = [] registry = scheduler.executor.registry registry.unregister(rule_id) if previous_rule is not None: registry.register(previous_rule) if was_paused: scheduler.pause_rule(rule_id) else: scheduler.resume_rule(rule_id) store.data.clear() store.data.update(copy.deepcopy(world_before)) for name, persist in ( ("world", store.save), ("scheduler", lambda: scheduler.save_state(self._state_file())), ): try: persist() except Exception as exc: # noqa: BLE001 - collect compensation evidence errors.append(f"{name}: {exc}") return errors def _commit_change( self, store: Any, scheduler: AutomationScheduler, *, actor: str, action: str, rule_id: str, mutation: Callable[[], tuple[dict[str, Any], Rule, dict[str, Any]]], confirmation: dict[str, Any] | None = None, ) -> dict[str, Any]: registry = scheduler.executor.registry try: previous_rule = registry.get(rule_id) except KeyError: previous_rule = None was_paused = rule_id in scheduler.paused_rules() world_before = copy.deepcopy(store.data) try: result, audit_rule, rationale = mutation() confirmation = confirmation or {} self._audit_change( store, actor=actor, action=action, rule=audit_rule, rationale={ **rationale, "confirmId": confirmation.get("confirmId"), "approver": self._authenticated_actor(actor), }, before_snapshot=confirmation.get("beforeSnapshot"), evidence_refs=list(confirmation.get("evidenceRefs") or []), ) self._persist(store, scheduler) return result except Exception as exc: rollback_errors = self._rollback_change( store, scheduler, rule_id=rule_id, previous_rule=previous_rule, was_paused=was_paused, world_before=world_before, ) if isinstance(exc, GovernanceRuleError) and exc.code != "PERSISTENCE_FAILED": raise detail = f"治理规则变更失败并已回滚: {exc}" if rollback_errors: detail += f";补偿持久化异常: {'; '.join(rollback_errors)}" raise GovernanceRuleError("PERSISTENCE_FAILED", detail) from exc # ---------------- 规则 CRUD ---------------- def _build_rule(self, raw: dict[str, Any]) -> Rule: rule = Rule.from_dict(raw) rule.validate() return rule def _rule_view(self, rule: Rule, scheduler: AutomationScheduler) -> dict[str, Any]: view = rule.as_dict() view["paused"] = rule.rule_id in scheduler.paused_rules() return view def _require_rule(self, rule_id: str, scheduler: AutomationScheduler) -> Rule: try: return scheduler.executor.registry.get(rule_id) except KeyError as exc: raise GovernanceRuleError("RULE_NOT_FOUND", f"规则 {rule_id} 不存在") from exc def list_rules(self, *, enabled: bool | None = None) -> list[dict[str, Any]]: scheduler = self._scheduler() return [ self._rule_view(rule, scheduler) for rule in scheduler.executor.registry.list(enabled=enabled) ] def get_rule(self, rule_id: str) -> dict[str, Any]: scheduler = self._scheduler() rule = self._require_rule(rule_id, scheduler) return self._rule_view(rule, scheduler) def stage_change( self, action: str, payload: dict[str, Any], *, session_id: str, ) -> Any: """Validate and freeze a governance mutation without changing rule state.""" from server.agent_core import harness scheduler = self._scheduler() raw = dict(payload or {}) if action == "governance.rule.create": rule_id = str(raw.get("ruleId") or "").strip() if rule_id and not RULE_ID_PATTERN.fullmatch(rule_id): raise GovernanceRuleError( "INVALID_RULE_ID", "ruleId 只能是字母数字开头,长度 1-128 的标识符", ) raw["ruleId"] = rule_id or f"r-{uuid.uuid4().hex[:12]}" try: rule = self._build_rule(raw) except (KeyError, TypeError, ValueError) as exc: raise GovernanceRuleError("INVALID_RULE", str(exc)) from exc if any(item.rule_id == rule.rule_id for item in scheduler.executor.registry.list()): raise GovernanceRuleError("RULE_EXISTS", f"规则 {rule.rule_id} 已存在") params = {"rule": rule.as_dict()} summary = [f"创建规则 {rule.rule_id}", f"房间 {rule.room_id},档位 {rule.gear}"] elif action == "governance.rule.update": rule_id = str(raw.pop("ruleId", "") or "") existing = self._require_rule(rule_id, scheduler) patch = dict(raw.get("patch") or {}) provided_id = patch.get("ruleId") if provided_id is not None and str(provided_id) != rule_id: raise GovernanceRuleError("RULE_ID_MISMATCH", "请求体 ruleId 必须与路径规则一致") merged = existing.as_dict() merged.update({key: value for key, value in patch.items() if key != "ruleId"}) merged["ruleId"] = rule_id try: updated = self._build_rule(merged) except (KeyError, TypeError, ValueError) as exc: raise GovernanceRuleError("INVALID_RULE", str(exc)) from exc params = {"ruleId": rule_id, "patch": patch} summary = [f"更新规则 {rule_id}", f"档位 {existing.gear} → {updated.gear}"] elif action in { "governance.rule.delete", "governance.rule.enable", "governance.rule.disable", }: rule_id = str(raw.get("ruleId") or "") existing = self._require_rule(rule_id, scheduler) params = {"ruleId": rule_id} verb = action.rsplit(".", 1)[-1] summary = [f"{verb} 规则 {rule_id}", f"当前启用状态 {existing.enabled}"] else: raise GovernanceRuleError("UNSUPPORTED_ACTION", f"不支持的治理动作 {action}") return harness.stage_confirmation( session_id, action, params, title=f"治理规则变更:{action.rsplit('.', 1)[-1]}", summary_lines=summary, ) def apply_confirmed_change( self, action: str, params: dict[str, Any], *, actor: str, confirm_id: str, before_snapshot: str | None, evidence_refs: Sequence[str] = (), ) -> dict[str, Any]: confirmation = { "confirmId": confirm_id, "beforeSnapshot": before_snapshot, "evidenceRefs": list(evidence_refs), } if action == "governance.rule.create": return self.create_rule(dict(params.get("rule") or {}), actor=actor, confirmation=confirmation) rule_id = str(params.get("ruleId") or "") if action == "governance.rule.update": return self.update_rule( rule_id, dict(params.get("patch") or {}), actor=actor, confirmation=confirmation, ) if action == "governance.rule.delete": return self.delete_rule(rule_id, actor=actor, confirmation=confirmation) if action == "governance.rule.enable": return self.set_enabled(rule_id, True, actor=actor, confirmation=confirmation) if action == "governance.rule.disable": return self.set_enabled(rule_id, False, actor=actor, confirmation=confirmation) raise GovernanceRuleError("UNSUPPORTED_ACTION", f"不支持的治理动作 {action}") def create_rule( self, payload: dict[str, Any], *, actor: str = "web", confirmation: dict[str, Any] | None = None, ) -> dict[str, Any]: scheduler = self._scheduler() store = self._store() raw = dict(payload or {}) rule_id = str(raw.get("ruleId") or "").strip() if rule_id and not RULE_ID_PATTERN.fullmatch(rule_id): self._audit_failure( store, scheduler, actor=actor, action="governance.rule.create", rule_id=rule_id, payload=raw, code="INVALID_RULE_ID", message="ruleId 只能是字母数字开头,长度 1-128 的标识符", ) raise GovernanceRuleError( "INVALID_RULE_ID", "ruleId 只能是字母数字开头,长度 1-128 的标识符", ) rule_id = rule_id or f"r-{uuid.uuid4().hex[:12]}" raw["ruleId"] = rule_id try: rule = self._build_rule(raw) if any(existing.rule_id == rule.rule_id for existing in scheduler.executor.registry.list()): raise GovernanceRuleError("RULE_EXISTS", f"规则 {rule.rule_id} 已存在") except GovernanceRuleError as exc: self._audit_failure( store, scheduler, actor=actor, action="governance.rule.create", rule_id=rule_id, payload=raw, code=exc.code, message=str(exc), ) raise except (ValueError, TypeError, KeyError) as exc: self._audit_failure( store, scheduler, actor=actor, action="governance.rule.create", rule_id=rule_id, payload=raw, code="INVALID_RULE", message=str(exc), ) raise GovernanceRuleError("INVALID_RULE", str(exc)) from exc def mutation() -> tuple[dict[str, Any], Rule, dict[str, Any]]: try: scheduler.executor.registry.register(rule) except ValueError as exc: raise GovernanceRuleError("RULE_EXISTS", f"规则 {rule.rule_id} 已存在") from exc return self._rule_view(rule, scheduler), rule, {"rule": rule.as_dict()} return self._commit_change( store, scheduler, actor=actor, action="governance.rule.create", rule_id=rule.rule_id, mutation=mutation, confirmation=confirmation, ) def update_rule( self, rule_id: str, payload: dict[str, Any], *, actor: str = "web", confirmation: dict[str, Any] | None = None, ) -> dict[str, Any]: scheduler = self._scheduler() store = self._store() existing = self._require_rule(rule_id, scheduler) raw = dict(payload or {}) provided_id = raw.get("ruleId") if provided_id is not None and str(provided_id) != rule_id: self._audit_failure( store, scheduler, actor=actor, action="governance.rule.update", rule_id=rule_id, payload=raw, code="RULE_ID_MISMATCH", message="请求体 ruleId 必须与路径规则一致", ) raise GovernanceRuleError("RULE_ID_MISMATCH", "请求体 ruleId 必须与路径规则一致") merged = existing.as_dict() merged.update({key: value for key, value in raw.items() if key != "ruleId"}) merged["ruleId"] = rule_id try: rule = self._build_rule(merged) except (ValueError, TypeError, KeyError) as exc: self._audit_failure( store, scheduler, actor=actor, action="governance.rule.update", rule_id=rule_id, payload=raw, code="INVALID_RULE", message=str(exc), ) raise GovernanceRuleError("INVALID_RULE", str(exc)) from exc def mutation() -> tuple[dict[str, Any], Rule, dict[str, Any]]: scheduler.executor.registry.unregister(rule_id) try: scheduler.executor.registry.register(rule) except (ValueError, TypeError) as exc: raise GovernanceRuleError("RULE_CONFLICT", str(exc)) from exc return ( self._rule_view(rule, scheduler), rule, {"before": existing.as_dict(), "after": rule.as_dict()}, ) return self._commit_change( store, scheduler, actor=actor, action="governance.rule.update", rule_id=rule_id, mutation=mutation, confirmation=confirmation, ) def delete_rule( self, rule_id: str, *, actor: str = "web", confirmation: dict[str, Any] | None = None, ) -> dict[str, Any]: scheduler = self._scheduler() store = self._store() rule = self._require_rule(rule_id, scheduler) def mutation() -> tuple[dict[str, Any], Rule, dict[str, Any]]: scheduler.executor.registry.unregister(rule_id) scheduler.resume_rule(rule_id) return {"deleted": True, "ruleId": rule_id}, rule, {"deleted": True} return self._commit_change( store, scheduler, actor=actor, action="governance.rule.delete", rule_id=rule_id, mutation=mutation, confirmation=confirmation, ) def set_enabled( self, rule_id: str, enabled: bool, *, actor: str = "web", confirmation: dict[str, Any] | None = None, ) -> dict[str, Any]: scheduler = self._scheduler() store = self._store() existing = self._require_rule(rule_id, scheduler) if existing.enabled == enabled: return self._rule_view(existing, scheduler) rule = Rule.from_dict({**existing.as_dict(), "enabled": enabled}) try: rule.validate() except (ValueError, TypeError) as exc: self._audit_failure( store, scheduler, actor=actor, action="governance.rule.enable" if enabled else "governance.rule.disable", rule_id=rule_id, payload=existing.as_dict(), code="INVALID_RULE", message=str(exc), ) raise GovernanceRuleError("INVALID_RULE", str(exc)) from exc action = "governance.rule.enable" if enabled else "governance.rule.disable" def mutation() -> tuple[dict[str, Any], Rule, dict[str, Any]]: scheduler.executor.registry.unregister(rule_id) scheduler.executor.registry.register(rule) return ( self._rule_view(rule, scheduler), rule, { "before": {"enabled": existing.enabled}, "after": {"enabled": enabled}, }, ) return self._commit_change( store, scheduler, actor=actor, action=action, rule_id=rule_id, mutation=mutation, confirmation=confirmation, ) def rule_audit(self, rule_id: str, *, limit: int = 50) -> dict[str, Any]: store = self._store() events = [event for event in (store.data.get("auditEvents") or []) if _event_targets_rule(event, rule_id)] return { "ruleId": rule_id, "total": len(events), "events": events[-limit:], } # ---------------- 审计统计 ---------------- def audit_stats( self, *, start_time: str | None = None, end_time: str | None = None, module: str | None = None, action: str | None = None, result: str | None = None, granularity: str = "day", ) -> dict[str, Any]: store = self._store() from server.agent_core.audit_mirror import AuditMirror mirror_events = AuditMirror( getattr(store, "tenant_uuid", "platform") or "platform", getattr(store, "world_key", "default") or "default", ).read_events() if mirror_events: events, source = mirror_events, "mirror" else: events, source = store.data.get("auditEvents") or [], "world" stats = audit_stats( events, start_time=start_time, end_time=end_time, module=module, action=action, result=result, granularity=granularity, ) stats["source"] = source return stats