verae-mcp-timestamping-server/src/verae_mcp_timestamping_server/metadata_history.py
Cursor Agent 394c46ebfc
Send global stamps as a verae-meta-git/1 history.
Optional metadata or restamp builds the same commit history as node-red. A call that omits both stays digest-only with restamp false. Local and shared stamps reject metadata.
2026-10-11 05:37:25 +00:00

207 lines
6.8 KiB
Python

"""verae-meta-git/1 documents, matching node-red-contrib-verae lib/metadata-history.js."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
SCHEMA = "verae-meta-git/1"
def is_plain_object(value: Any) -> bool:
return isinstance(value, dict)
def is_blank(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return value.strip() == ""
if isinstance(value, list):
return len(value) == 0
if isinstance(value, dict):
return len(value) == 0
return False
def _clone(value: Any) -> Any:
if isinstance(value, list):
return [_clone(item) for item in value]
if isinstance(value, dict):
return {key: _clone(item) for key, item in value.items()}
return value
def _scalar(value: Any) -> str:
if isinstance(value, bool) or value is None or isinstance(value, str):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, int) and not isinstance(value, bool):
return json.dumps(value)
if isinstance(value, float):
if value.is_integer():
return json.dumps(int(value))
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def canonical(value: Any) -> str:
if isinstance(value, list):
return "[" + ",".join(canonical(item) for item in value) + "]"
if isinstance(value, dict):
keys = sorted(value)
return "{" + ",".join(_scalar(key) + ":" + canonical(value[key]) for key in keys) + "}"
return _scalar(value)
def _same(a: Any, b: Any) -> bool:
return canonical(a) == canonical(b)
def extend_snapshot(base: dict | None, patch: dict | None) -> dict:
out = _clone(base) if isinstance(base, dict) else {}
if not isinstance(patch, dict):
return out
for key, nxt in patch.items():
if is_blank(nxt):
continue
prev = out.get(key, None)
has = key in out
if has and isinstance(prev, dict) and isinstance(nxt, dict):
out[key] = extend_snapshot(prev, nxt)
elif isinstance(prev, list) and isinstance(nxt, list):
extra = [_clone(item) for item in nxt if not is_blank(item)]
if extra:
out[key] = list(prev) + extra
elif isinstance(nxt, list):
extra = [_clone(item) for item in nxt if not is_blank(item)]
if not extra:
continue
out[key] = (list(prev) + extra) if isinstance(prev, list) else extra
elif isinstance(nxt, dict):
nested = extend_snapshot(prev if isinstance(prev, dict) else {}, nxt)
if not is_blank(nested):
out[key] = nested
else:
out[key] = _clone(nxt)
return out
def changes_between(before: dict | None, after: dict | None) -> dict:
added: dict[str, Any] = {}
extended: dict[str, Any] = {}
updated: dict[str, Any] = {}
prior = before if isinstance(before, dict) else {}
nxt = after if isinstance(after, dict) else {}
for key in sorted(set(prior) | set(nxt)):
if key not in nxt:
continue
if key not in prior:
added[key] = _clone(nxt[key])
continue
if _same(prior[key], nxt[key]):
continue
if isinstance(prior[key], dict) and isinstance(nxt[key], dict):
nested = changes_between(prior[key], nxt[key])
if not is_blank(nested["added"]) or not is_blank(nested["extended"]) or not is_blank(nested["updated"]):
extended[key] = nested
elif (
isinstance(prior[key], list)
and isinstance(nxt[key], list)
and len(nxt[key]) >= len(prior[key])
):
appended = nxt[key][len(prior[key]) :]
if appended:
extended[key] = {"appended": _clone(appended)}
else:
updated[key] = {"from": _clone(prior[key]), "to": _clone(nxt[key])}
return {"added": added, "extended": extended, "updated": updated}
def _commit_id(commit: dict) -> str:
material = canonical(
{
"parent": commit["parent"],
"at": commit["at"],
"message": commit["message"],
"snapshot": commit["snapshot"],
}
)
return hashlib.sha256(material.encode()).hexdigest()
def is_history(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("schema") == SCHEMA
and isinstance(value.get("commits"), list)
and isinstance(value.get("snapshot"), dict)
)
def _history_from_snapshot(snapshot: dict, at: str) -> dict:
stored = _clone(snapshot)
commit = {
"parent": None,
"at": at,
"message": "stamp",
"snapshot": stored,
"changes": changes_between({}, stored),
}
commit["id"] = _commit_id(commit)
return {
"schema": SCHEMA,
"head": commit["id"],
"snapshot": _clone(stored),
"commits": [commit],
}
def _coerce(existing: Any, at: str) -> dict:
if is_history(existing):
return _clone(existing)
if isinstance(existing, dict) and not is_blank(existing):
return _history_from_snapshot(existing, at)
return {"schema": SCHEMA, "head": None, "snapshot": {}, "commits": []}
def _has_changes(changes: dict) -> bool:
return not is_blank(changes["added"]) or not is_blank(changes["extended"]) or not is_blank(changes["updated"])
def append_metadata(
existing: Any,
patch: dict | None,
*,
restamp: bool = False,
at: str | None = None,
prior_at: str | None = None,
message: str | None = None,
) -> dict | None:
if not at:
at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
blank_patch = not isinstance(patch, dict) or is_blank(patch) or is_blank(extend_snapshot({}, patch))
if not restamp and blank_patch and not is_history(existing) and (existing is None or is_blank(existing)):
return None
doc = _coerce(existing, prior_at or at)
head = doc.get("head")
if not restamp and blank_patch:
return doc if head else None
snapshot = extend_snapshot(doc.get("snapshot") or {}, patch if isinstance(patch, dict) else {})
changes = changes_between(doc.get("snapshot") or {}, snapshot)
if not restamp and not _has_changes(changes) and head:
return doc
msg = message or ("restamp" if head else "stamp")
commit = {
"parent": head,
"at": at,
"message": msg,
"snapshot": snapshot,
"changes": changes,
}
commit["id"] = _commit_id(commit)
doc["commits"] = list(doc.get("commits") or []) + [commit]
doc["head"] = commit["id"]
doc["snapshot"] = snapshot
return doc