726 lines
27 KiB
Python
726 lines
27 KiB
Python
# ============================================================
|
||
# 治理规则域(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
|