Files
codex-app-server/cloud/chv/activity.py
T
host 9759be47bb feat(chv): 对齐 Muse P12——审计轨迹与活动视图
统一审计记录模型(时间/主体/动作/对象/结果/原因/关联任务/
关联审批)+ 追加写 JSONL + SHA-256 哈希链(改写/删除/调序可
被 verify 发现)+ 字段级白名单清洗(密钥/token 值/对话原文
绝不进审计);事件分类覆盖任务派发、VM 生命周期、同步、审批、
支付、配额、记忆、后台、推理/遥测;from_legacy_row 承接现有
audit_log 行;导出选 JSON。

活动视图后端:计划登记(已做 vs 计划做,流转受控)+ 已批准
权限登记(接 P5 describe 形状)+ activity_summary 纯函数;
结构化审批卡数据(风险分级 + 接受/拒绝明确动作 + 五档授权
选项,永久档须显式 opt-in)+ card_matches_channel 校验卡与
P5 通道载荷一致。

只做后端 + 数据接口,前端视图留接口;P3/P5 不在本基线,
接线走 dict 契约,集成由 round-11 合并;审计持久化先落独立
JSONL,待 P7 状态库就绪后迁移。真机验证排队在 round-11 之后。

新测试 43/43,全量 cloud 套件 1910 OK(skipped=8);
mypy 在改动文件上干净。
2026-11-06 22:38:19 +00:00

275 lines
11 KiB
Python

"""活动视图后端(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),
},
}