aps-agent/server/aps_domain/governance_rules.py

726 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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