Initialize repository snapshot
This commit is contained in:
271
engines/console/receipts.py
Normal file
271
engines/console/receipts.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Console Engine Receipt Emitter
|
||||
|
||||
Appends receipts to the Console scroll and maintains the per-engine Merkle root.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
||||
|
||||
try:
|
||||
import blake3 # type: ignore
|
||||
except ImportError:
|
||||
blake3 = None # fallback to hashlib
|
||||
|
||||
|
||||
ENGINE_ID = "engine:console"
|
||||
|
||||
# Paths relative to VaultMesh repo root
|
||||
DEFAULT_EVENTS_PATH = "receipts/console/console_events.jsonl"
|
||||
DEFAULT_ROOT_PATH = "receipts/console/ROOT.console.txt"
|
||||
|
||||
|
||||
ReceiptType = Literal[
|
||||
"console_genesis",
|
||||
"console_session_start",
|
||||
"console_session_end",
|
||||
"console_command",
|
||||
"console_file_edit",
|
||||
"console_tool_call",
|
||||
"console_approval_request", # Request for approval (pending)
|
||||
"console_approval", # Decision on approval request
|
||||
"console_git_commit",
|
||||
"console_agent_spawn",
|
||||
]
|
||||
|
||||
|
||||
class ConsoleReceipt(TypedDict):
|
||||
"""Schema for Console receipts."""
|
||||
ts: str
|
||||
engine_id: str
|
||||
type: ReceiptType
|
||||
session_id: Optional[str]
|
||||
payload: Dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleReceiptEmitter:
|
||||
"""
|
||||
Local filesystem emitter for Console receipts.
|
||||
|
||||
Appends receipts to console_events.jsonl and updates ROOT.console.txt
|
||||
with the computed Merkle root.
|
||||
|
||||
NOTE: This uses a simple O(n) recompute for the Merkle root.
|
||||
TODO: Replace with shared receipts frontier or Rust FFI for O(log n) updates.
|
||||
"""
|
||||
events_path: str = DEFAULT_EVENTS_PATH
|
||||
root_path: str = DEFAULT_ROOT_PATH
|
||||
vaultmesh_root: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Resolve paths relative to VaultMesh root."""
|
||||
if self.vaultmesh_root is None:
|
||||
# Try to auto-detect from environment or use current directory
|
||||
self.vaultmesh_root = os.environ.get(
|
||||
"VAULTMESH_ROOT",
|
||||
os.getcwd()
|
||||
)
|
||||
|
||||
# Make paths absolute
|
||||
root = Path(self.vaultmesh_root)
|
||||
self.events_path = str(root / self.events_path)
|
||||
self.root_path = str(root / self.root_path)
|
||||
|
||||
def _ensure_dirs(self) -> None:
|
||||
"""Ensure parent directories exist."""
|
||||
os.makedirs(os.path.dirname(self.events_path), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(self.root_path), exist_ok=True)
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
"""Return current UTC timestamp in ISO format."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
def _vmhash(self, data: bytes) -> str:
|
||||
"""VaultMesh hash with algorithm prefix (blake3 preferred)."""
|
||||
if blake3 is not None:
|
||||
return f"blake3:{blake3.blake3(data).hexdigest()}"
|
||||
|
||||
# Fallback for environments without blake3
|
||||
import hashlib
|
||||
|
||||
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
||||
|
||||
def _hex_part(self, value: str) -> str:
|
||||
"""Return hash hex component (strip optional algorithm prefix)."""
|
||||
return value.split(":", 1)[-1]
|
||||
|
||||
def _compute_merkle_root(self, hashes: List[str]) -> str:
|
||||
"""
|
||||
Compute Merkle root over a list of VaultMesh hashes.
|
||||
|
||||
Simple in-process implementation. O(n log n) time.
|
||||
TODO: Replace with incremental frontier for O(log n) updates.
|
||||
"""
|
||||
if not hashes:
|
||||
return self._vmhash(b"empty")
|
||||
if len(hashes) == 1:
|
||||
return hashes[0]
|
||||
|
||||
level = hashes[:]
|
||||
while len(level) > 1:
|
||||
next_level: List[str] = []
|
||||
for i in range(0, len(level), 2):
|
||||
left = level[i]
|
||||
right = level[i + 1] if i + 1 < len(level) else left
|
||||
combined = (self._hex_part(left) + self._hex_part(right)).encode("utf-8")
|
||||
next_level.append(self._vmhash(combined))
|
||||
level = next_level
|
||||
return level[0]
|
||||
|
||||
def _recompute_root(self) -> None:
|
||||
"""
|
||||
Re-scan the JSONL file, hash each line, and update ROOT.console.txt.
|
||||
|
||||
This is O(n) but simple and correct for Phase 1 (Nigredo).
|
||||
TODO: Replace with shared receipts frontier for O(log n) updates.
|
||||
"""
|
||||
hashes: List[str] = []
|
||||
count = 0
|
||||
if os.path.exists(self.events_path):
|
||||
with open(self.events_path, "rb") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.rstrip(b"\n")
|
||||
if not line:
|
||||
continue
|
||||
hashes.append(self._vmhash(line))
|
||||
count += 1
|
||||
root = self._compute_merkle_root(hashes)
|
||||
|
||||
# Write updated root file
|
||||
self._ensure_dirs()
|
||||
with open(self.root_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"# VaultMesh Console Root\n")
|
||||
f.write(f"engine_id={ENGINE_ID}\n")
|
||||
f.write(f"merkle_root={root}\n")
|
||||
f.write(f"events={count}\n")
|
||||
f.write(f"updated_at={self._now_iso()}\n")
|
||||
|
||||
def emit(
|
||||
self,
|
||||
receipt_type: ReceiptType,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
session_id: Optional[str] = None,
|
||||
ts: Optional[str] = None,
|
||||
) -> ConsoleReceipt:
|
||||
"""
|
||||
Emit a single Console receipt and update the engine root.
|
||||
|
||||
Args:
|
||||
receipt_type: One of the defined ReceiptType values
|
||||
payload: Domain-specific receipt data
|
||||
session_id: Session identifier (required for non-genesis receipts)
|
||||
ts: Optional timestamp override (ISO format)
|
||||
|
||||
Returns:
|
||||
The emitted receipt record
|
||||
|
||||
Example:
|
||||
emitter.emit(
|
||||
"console_session_start",
|
||||
{
|
||||
"agent_type": "opencode",
|
||||
"model_id": "claude-opus-4-5",
|
||||
"caller": "did:vm:human:karol",
|
||||
"project_path": "/root/work/vaultmesh"
|
||||
},
|
||||
session_id="session-1765123456",
|
||||
)
|
||||
"""
|
||||
self._ensure_dirs()
|
||||
|
||||
record: ConsoleReceipt = {
|
||||
"ts": ts or self._now_iso(),
|
||||
"engine_id": ENGINE_ID,
|
||||
"type": receipt_type,
|
||||
"session_id": session_id,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
# Append to scroll (compact JSON, one line)
|
||||
line = json.dumps(record, separators=(",", ":"))
|
||||
with open(self.events_path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
# Update Merkle root
|
||||
self._recompute_root()
|
||||
|
||||
return record
|
||||
|
||||
def get_root_info(self) -> Dict[str, Any]:
|
||||
"""Read and parse the current ROOT.console.txt file."""
|
||||
if not os.path.exists(self.root_path):
|
||||
return {
|
||||
"engine_id": ENGINE_ID,
|
||||
"merkle_root": self._vmhash(b"empty"),
|
||||
"events": 0,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
info = {}
|
||||
with open(self.root_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("#") or not line:
|
||||
continue
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
if key == "events":
|
||||
info[key] = int(value)
|
||||
else:
|
||||
info[key] = value
|
||||
return info
|
||||
|
||||
|
||||
# Convenience singleton for simple use
|
||||
_default_emitter: Optional[ConsoleReceiptEmitter] = None
|
||||
|
||||
|
||||
def get_emitter(vaultmesh_root: Optional[str] = None) -> ConsoleReceiptEmitter:
|
||||
"""Get or create the default emitter singleton."""
|
||||
global _default_emitter
|
||||
if _default_emitter is None:
|
||||
_default_emitter = ConsoleReceiptEmitter(vaultmesh_root=vaultmesh_root)
|
||||
return _default_emitter
|
||||
|
||||
|
||||
def emit_console_receipt(
|
||||
receipt_type: ReceiptType,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
session_id: Optional[str] = None,
|
||||
ts: Optional[str] = None,
|
||||
vaultmesh_root: Optional[str] = None,
|
||||
) -> ConsoleReceipt:
|
||||
"""
|
||||
Emit a Console receipt using the default emitter.
|
||||
|
||||
Convenience function that uses a singleton emitter instance.
|
||||
|
||||
Args:
|
||||
receipt_type: One of the defined ReceiptType values
|
||||
payload: Domain-specific receipt data
|
||||
session_id: Session identifier (required for non-genesis receipts)
|
||||
ts: Optional timestamp override (ISO format)
|
||||
vaultmesh_root: Optional VaultMesh repo root path
|
||||
|
||||
Returns:
|
||||
The emitted receipt record
|
||||
"""
|
||||
emitter = get_emitter(vaultmesh_root)
|
||||
return emitter.emit(
|
||||
receipt_type=receipt_type,
|
||||
payload=payload,
|
||||
session_id=session_id,
|
||||
ts=ts,
|
||||
)
|
||||
Reference in New Issue
Block a user