Files
codex-app-server/cloud/chv/presentation.py
T
host d4cef29cdb round-12: migrate P8 registries + P12 audit to AppStateStore
- app_state.py: host_state_db_path() (host-level DB, distinct from
  per-VM state_db_path); kv_get/kv_put/kv_delete on meta (namespaced
  keys; kv_put skips the no-secrets guard by design — registry docs
  hold user prompts, documented); audit_trail table (P12 record
  shape + hash chain, seq explicit); counts()/backup() gain
  audit_trail.
- swarm.py / presentation.py / browser_tasks.py: JSON-file backends
  replaced by KV rows (p8-swarm / p8-ledger / p8-browser-tasks);
  legacy JSON imported once on first load (renamed .migrated);
  corrupt row / unreadable store returns empty (fail-safe, same
  semantics as before; next successful save self-heals).
- audit_trail.py: StoreAuditTrail (same record shape, hash chain,
  filters, export as JSONL version, backed by audit_trail table);
  migrate_jsonl_to_store() moves a chain verbatim (broken chain
  refused, divergent seq refused, re-run idempotent). JSONL class
  kept for reading old files.
- P12 dict contracts now run against real P3/P5 code
  (ApprovalQueue.record -> build_approval_card ->
  card_matches_channel; CapabilityIssuer.issue -> describe ->
  PermissionRegistry.record_grant).
- P6->P3 local protocol test: real PrivsepClient + real worker
  server + real git status; worker git argv carries the real P3
  helper + useHttpPath; no token in argv/env/audit. runuser + HTTP
  pull stay real-machine-only (see gap docs).
- Gap docs updated: P8 migration done, guest-side browser follow-up
  explicitly left for the real-machine track (no task_id in the
  browser protocol yet; lifecycle needs a real VM to verify);
  P6 wiring + P12 migration marked done; production-readiness.md
  gains §20 (round-12 integration).
- Tests: new cloud/tests/test_chv_muse_round12.py, 24/24 OK;
  test_chv_muse_p8.py ledger corrupt test rewritten for the KV
  backend (+ legacy-import test). P7+P8: 95+1 tests OK.
2026-11-07 00:36:25 +00:00

221 lines
8.4 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.
"""P8 presentation:后台任务完成后“是否值得呈现”的评估,与
通知去重节流账本。
对齐 Muse B2(官方设计文):完成后台工作后评估是否值得呈现,
只在真正有新内容或需要输入时通知用户。
判定是确定性规则(不是模型判断):
- needs_input:需要用户输入 -> 通知。
- failed/interrupted:失败、需要决策 -> 通知。
- completed 且有实质新内容(新产物、改动文件、或显式 notify
标志)-> 通知;否则常规完成只记日志。
- cancelled / 未知终态 -> 只记日志(未知终态 fail-closed,
不通知)。
账本:宿主级 AppStateStore(round-12 已迁移,见
docs/chv-muse-p8-gap.md §6):state_dir/app-state.sqlite 的
``p8-ledger`` 命名空间,一行存整份账本;旧
notification-ledger.json 首次加载时自动导入并改名
``.migrated``;行损坏/库打不开时回空账本(去重信息丢了只会
导致一次重复通知,不影响任务本身),下次成功保存即覆盖自愈。
同一任务同一原因在节流窗口(3600 秒,本项目自定)内只通知一次。
"""
from __future__ import annotations
import json
import logging
import os
import time
from cloud.chv.app_state import AppStateStore, host_state_db_path
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
# 同一任务同一原因的通知节流窗口(秒,本项目自定)。
NOTIFY_THROTTLE_SECONDS = 3600
_LEDGER_FILENAME = "notification-ledger.json"
_KV_NAMESPACE = "p8-ledger"
_KV_KEY = "registry"
_MAX_LEDGER_ENTRIES = 500
def evaluate_presentation(result: dict) -> dict:
"""评估一次后台任务终态是否值得呈现。
result 键:status(completed/failed/interrupted/cancelled/
needs_input)、new_artifacts(list)、changed_files(list)、
notify(bool,任务显式要求通知)。返回 {"present", "reason"};
present 为 False 时调用方只记日志、不通知。"""
if not isinstance(result, dict):
return {"present": False, "reason": "unknown_status"}
status = result.get("status")
if status == "needs_input":
return {"present": True, "reason": "needs_input"}
if status in ("failed", "interrupted"):
return {"present": True, "reason": "failed_needs_decision"}
if status == "completed":
return _evaluate_completed(result)
if status == "cancelled":
return {"present": False, "reason": "cancelled"}
return {"present": False, "reason": "unknown_status"}
def _evaluate_completed(result: dict) -> dict:
"""完成态分支:有实质新内容才通知。"""
artifacts = result.get("new_artifacts")
changed = result.get("changed_files")
has_new = bool(result.get("notify"))
if isinstance(artifacts, list) and artifacts:
has_new = True
if isinstance(changed, list) and changed:
has_new = True
if has_new:
return {"present": True, "reason": "substantive_new_content"}
return {"present": False, "reason": "routine_completed"}
def _open_store(state_dir: str) -> AppStateStore:
"""打开宿主级状态库(每次调用新建连接,用完即关)。"""
store = AppStateStore(host_state_db_path(state_dir))
store.open()
return store
def _ledger_path(state_dir: str) -> str:
"""旧版通知账本文件路径(只用于一次性迁移导入)。"""
return os.path.join(state_dir, _LEDGER_FILENAME)
def _quarantine(path: str) -> None:
"""把损坏的账本挪到 <path>.corrupt 旁(尽力而为)。"""
try:
os.replace(path, path + ".corrupt")
except OSError:
_LOGGER.warning("chv_ledger_quarantine_failed path=%s", path)
def _load_ledger(state_dir: str) -> dict:
"""读账本:KV 行缺失时走一次性旧文件导入;行损坏/库打不
开时回空结构(去重信息丢了只会导致一次重复通知,不影响
任务本身;下次成功保存即覆盖自愈)。"""
try:
with _open_store(state_dir) as store:
raw = store.kv_get(_KV_NAMESPACE, _KV_KEY)
except Exception:
_LOGGER.warning("chv_ledger_store_unreadable state_dir=%s",
state_dir)
return {"notified": {}}
if raw is None:
return _import_legacy(state_dir)
try:
data = json.loads(raw)
except ValueError:
_LOGGER.warning("chv_ledger_corrupt state_dir=%s", state_dir)
return {"notified": {}}
if not isinstance(data, dict) \
or not isinstance(data.get("notified"), dict):
return {"notified": {}}
return data
def _import_legacy(state_dir: str) -> dict:
"""一次性导入旧 notification-ledger.json:合法即进 KV 并
把文件改名 ``.migrated``;无文件回空;损坏则 quarantine
后回空(旧口径)。导入失败时返回读到的数据、文件保留,
下次加载重试。"""
path = _ledger_path(state_dir)
try:
with open(path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return {"notified": {}}
except (OSError, ValueError):
_quarantine(path)
return {"notified": {}}
if not isinstance(data, dict) \
or not isinstance(data.get("notified"), dict):
_quarantine(path)
return {"notified": {}}
try:
with _open_store(state_dir) as store:
store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data))
except Exception:
_LOGGER.warning("chv_ledger_import_failed state_dir=%s",
state_dir)
return data
try:
os.replace(path, path + ".migrated")
except OSError:
pass
return data
def _save_ledger(state_dir: str, data: dict) -> None:
"""落盘账本到 KV 行(截断到上限);写失败只记日志。"""
notified = data.get("notified", {})
if len(notified) > _MAX_LEDGER_ENTRIES:
items = sorted(notified.items(), key=lambda kv: kv[1])
data["notified"] = dict(items[-_MAX_LEDGER_ENTRIES:])
try:
with _open_store(state_dir) as store:
store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data))
except Exception:
_LOGGER.warning("chv_ledger_save_failed state_dir=%s", state_dir)
class NotificationLedger:
"""通知去重节流账本(每任务每原因节流)。
should_notify(task_id, reason) 在窗口内未通知过时回 True
并记下这次通知;窗口内已通知过回 False。账本落在宿主级
AppStateStore(round-12 已迁移,见模块 docstring)。
"""
def __init__(self, state_dir: str) -> None:
"""建账本:state_dir 为空即 ValueError。"""
if not state_dir or not isinstance(state_dir, str):
raise ValueError("state_dir required")
self.state_dir = state_dir
def should_notify(self, task_id: str, reason: str,
now: float | None = None) -> bool:
"""该任务该原因现在能不能通知:窗口内没通知过就记下
并回 True,否则回 False。task_id/reason 为空即
ValueError。"""
if now is None:
now = time.time()
if not task_id or not isinstance(task_id, str):
raise ValueError("task_id required")
if not reason or not isinstance(reason, str):
raise ValueError("reason required")
key = "%s:%s" % (task_id, reason)
data = _load_ledger(self.state_dir)
notified = data.get("notified", {})
last = notified.get(key)
if isinstance(last, (int, float)) \
and now - last < NOTIFY_THROTTLE_SECONDS:
return False
notified[key] = now
_save_ledger(self.state_dir, data)
return True
def present_or_log(result: dict, ledger: NotificationLedger,
task_id: str,
now: float | None = None) -> dict:
"""评估 + 去重的一站式入口(worker/push 终态派发处调它)。
返回 {"present", "reason", "notified"}:present 是评估结论,
notified 是本次是否真的发出通知(评估通过但窗口内已通知
过时 notified 为 False、只记日志)。"""
if now is None:
now = time.time()
decision = evaluate_presentation(result)
if not decision["present"]:
return {"present": False, "reason": decision["reason"],
"notified": False}
notified = ledger.should_notify(task_id, decision["reason"], now)
return {"present": True, "reason": decision["reason"],
"notified": notified}