aps-agent/server/aps_domain/governance_rules.py

726 lines
27 KiB
Python
Raw Normal View History

# ============================================================
# 治理规则域(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