"""活动视图后端(feat-chv-muse-p12,纯标准库 + atomic_file)。 接 Muse 官方要求(报告 §2.2 第 7 步、§2.6):完整审计轨迹展示 已做与计划做的事;头像下方显示正在做什么的摘要,点头像可见 完整活动日志与已批准权限;审批是严格能力,绑定连接器/目的地 与用例。 本模块只给后端 + 数据接口,前端视图(客户端 UI)是另一侧的 工作,契约见各函数 docstring。 三件东西: - ``PlanStore``:计划登记——已做 vs 计划做。状态只认 pending/running/done/cancelled,流转 pending→running/ cancelled、running→done/cancelled,终态不可再动,非法流转 抛 ValueError。落 JSON 文件,原子写(0600)。 - ``PermissionRegistry``:已批准权限登记。接 P5 ``ScopedCapability.describe()`` 的 dict 形状(capability_id / tier / connector / host / use_case / session_id / task_id / methods / max_uses / uses_left / expires_at / revoked),不 import P5 模块——P5 不在本基线里,集成时由 round-11 合并。 ``list_active()`` 只返回未撤销、未过期、次数未用完的授权。 - ``activity_summary()``:纯函数,把计划列表 + 近期审计记录 + 有效授权拼成活动摘要:正在做 / 计划中 / 已做 / 近期记录 / 已批准权限数。正在做来自计划登记与审计记录,不是实时进程 探针;VM 内真实执行状态以执行器为准。 """ import json import os import time from cloud.chv.atomic_file import atomic_write_text # 计划状态与合法流转(终态 done/cancelled 不可再动)。 PLAN_STATUSES = ("pending", "running", "done", "cancelled") _PLAN_TRANSITIONS: dict[str, tuple[str, ...]] = { "pending": ("running", "cancelled"), "running": ("done", "cancelled"), "done": (), "cancelled": (), } def _load_json(path: str) -> dict: """读 JSON 文件:不存在或坏掉返回空 dict(调用方重建)。""" if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except (ValueError, OSError): return {} return data if isinstance(data, dict) else {} def _save_json(path: str, data: dict) -> None: """原子写 JSON 文件(0600,写失败原样抛错)。""" text = json.dumps(data, ensure_ascii=False, indent=2) atomic_write_text(path, text, mode=0o600) class PlanStore: """计划登记:一个项目一串计划,状态流转受控。""" def __init__(self, path: str) -> None: self.path = path self._data = _load_json(path) def add_plan(self, project_id: str, title: str, task_id: str = "", ts: float | None = None) -> dict: """登记一条计划(初始 pending),返回该计划。""" if not project_id: raise ValueError("project_id 不能为空") if not title: raise ValueError("title 不能为空") now = ts if ts is not None else time.time() plan = { "plan_id": "%d-%s" % (int(now * 1000), str(len(self._plans(project_id)))), "project_id": project_id, "title": title, "task_id": task_id, "status": "pending", "created_at": now, "updated_at": now, } self._data.setdefault(project_id, []).append(plan) _save_json(self.path, self._data) return dict(plan) def set_status(self, project_id: str, plan_id: str, status: str, ts: float | None = None) -> dict: """改计划状态:非法流转或未知 plan_id 抛 ValueError。""" if status not in PLAN_STATUSES: raise ValueError("未知计划状态:%s" % status) for plan in self._plans(project_id): if plan["plan_id"] != plan_id: continue allowed = _PLAN_TRANSITIONS[plan["status"]] if status not in allowed: raise ValueError( "计划 %s 不能从 %s 到 %s" % (plan_id, plan["status"], status)) plan["status"] = status plan["updated_at"] = ts if ts is not None else time.time() _save_json(self.path, self._data) return dict(plan) raise ValueError("未知计划:%s" % plan_id) def list_plans(self, project_id: str, status: str = "") -> list: """该项目的计划(副本,按创建时间排序,可按状态过滤)。""" plans = [dict(p) for p in self._plans(project_id)] plans.sort(key=lambda p: p.get("created_at", 0.0)) if status: plans = [p for p in plans if p.get("status") == status] return plans def _plans(self, project_id: str) -> list: """该项目的计划列表(内部引用,调用方不得外泄)。""" items = self._data.get(project_id, []) return items if isinstance(items, list) else [] class PermissionRegistry: """已批准权限登记:接 P5 能力描述,不存任何凭证值。 授权只含绑定(连接器/主机/用例/档位/有效期/次数余量), 与 P5 ``ScopedCapability.describe()`` 的字段一一对应。 """ def __init__(self, path: str) -> None: self.path = path self._data = _load_json(path) def record_grant(self, project_id: str, capability: dict, ts: float | None = None) -> dict: """登记一条已签发的授权,返回该授权(副本)。 capability 是 P5 describe() 形状的 dict;缺 capability_id/tier/connector/host/use_case 任一即 抛 ValueError。同 capability_id 重复登记视为更新。 """ if not project_id: raise ValueError("project_id 不能为空") required = ("capability_id", "tier", "connector", "host", "use_case") for key in required: if not capability.get(key): raise ValueError("能力描述缺字段:%s" % key) now = ts if ts is not None else time.time() grant = { "capability_id": str(capability["capability_id"]), "project_id": project_id, "tier": str(capability["tier"]), "connector": str(capability["connector"]), "host": str(capability["host"]), "use_case": str(capability["use_case"]), "session_id": capability.get("session_id") or "", "task_id": capability.get("task_id") or "", "methods": sorted(capability.get("methods") or []), "max_uses": capability.get("max_uses"), "uses_left": capability.get("uses_left"), "expires_at": capability.get("expires_at"), "revoked": bool(capability.get("revoked", False)), "granted_at": now, } grants = self._grants(project_id) for index, old in enumerate(grants): if old["capability_id"] == grant["capability_id"]: grants[index] = grant break else: grants.append(grant) self._data.setdefault(project_id, grants) _save_json(self.path, self._data) return dict(grant) def note_use(self, project_id: str, capability_id: str) -> bool: """记一次使用:次数余量减一;未知 id 或已无余量返回 False。""" for grant in self._grants(project_id): if grant["capability_id"] != capability_id: continue if grant.get("revoked"): return False left = grant.get("uses_left") if left is None: return True if left <= 0: return False grant["uses_left"] = left - 1 _save_json(self.path, self._data) return True return False def revoke(self, project_id: str, capability_id: str) -> bool: """撤销一条授权:未知 id 返回 False,已撤销也返回 False。""" for grant in self._grants(project_id): if grant["capability_id"] != capability_id: continue if grant.get("revoked"): return False grant["revoked"] = True _save_json(self.path, self._data) return True return False def list_active(self, project_id: str, now: float | None = None) -> list: """该项目当前有效的授权:未撤销、未过期、次数未用完。""" at = now if now is not None else time.time() active = [dict(g) for g in self._grants(project_id) if _grant_active(g, at)] active.sort(key=lambda g: g.get("granted_at", 0.0)) return active def list_all(self, project_id: str) -> list: """该项目全部授权(含已撤销/已过期,副本,按签发排序)。""" grants = [dict(g) for g in self._grants(project_id)] grants.sort(key=lambda g: g.get("granted_at", 0.0)) return grants def _grants(self, project_id: str) -> list: """该项目的授权列表(内部引用,调用方不得外泄)。""" items = self._data.get(project_id, []) return items if isinstance(items, list) else [] def _grant_active(grant: dict, now: float) -> bool: """单条授权是否当前有效(未撤销 + 未过期 + 有余量)。""" if grant.get("revoked"): return False expires_at = grant.get("expires_at") if expires_at is not None and float(expires_at) <= now: return False left = grant.get("uses_left") if left is not None and int(left) <= 0: return False return True def activity_summary(plans: list, recent_records: list, grants: list) -> dict: """拼活动摘要(纯函数):正在做 / 计划中 / 已做 / 近期 / 授权。 入参是调用方已按项目过滤好的列表:plans 为计划 dict 列表, recent_records 为近期审计记录(倒序),grants 为有效授权。 返回只含描述性字段,不含任何密钥与对话原文。 """ running = [p for p in plans if p.get("status") == "running"] pending = [p for p in plans if p.get("status") == "pending"] done = [p for p in plans if p.get("status") == "done"] current = running[0]["title"] if running else "" recent = [{"ts": r.get("ts"), "action": r.get("action"), "category": r.get("category"), "result": r.get("result"), "object_type": r.get("object_type"), "object_id": r.get("object_id")} for r in recent_records] return { "current": current, "running": running, "planned": pending, "done": done, "recent": recent, "granted_permissions": grants, "counts": { "running": len(running), "planned": len(pending), "done": len(done), "granted": len(grants), }, }