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
|