mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
429 lines
14 KiB
Python
429 lines
14 KiB
Python
"""Locked, receipt-backed skill delivery state.
|
|
|
|
The module is deliberately latent in v3-02: callers may prepare and bind
|
|
deliveries, but existing execution guards do not consume the receipts until
|
|
the command-gate migration branch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from . import state
|
|
from .skill_policy import validate_skill_selection
|
|
|
|
__all__ = [
|
|
"DeliveryReservation",
|
|
"HermesSkillViewAdapter",
|
|
"SkillViewResult",
|
|
"advance_context_generation",
|
|
"bind_task",
|
|
"complete_delivery",
|
|
"get_binding",
|
|
"binding_readiness",
|
|
"get_delivery",
|
|
"record_binding_turn",
|
|
"record_delivery_turn",
|
|
"prepare_delivery",
|
|
]
|
|
|
|
_FILE_NAME = "skills.json"
|
|
_SCHEMA_VERSION = 1
|
|
_MAX_DELIVERIES = 200
|
|
_PREPARING_TTL_SECONDS = 300
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _digest(value: str) -> str:
|
|
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _preparing_expired(entry: dict[str, Any]) -> bool:
|
|
value = str(entry.get("expires_at") or "").strip()
|
|
if value:
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00")) <= datetime.now(UTC)
|
|
except ValueError:
|
|
return True
|
|
updated = str(entry.get("updated_at") or entry.get("created_at") or "").strip()
|
|
if not updated:
|
|
return True
|
|
try:
|
|
started = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return True
|
|
return (datetime.now(UTC) - started).total_seconds() >= _PREPARING_TTL_SECONDS
|
|
|
|
|
|
def _preparing_expires_at() -> str:
|
|
from datetime import timedelta
|
|
|
|
return (
|
|
(datetime.now(UTC) + timedelta(seconds=_PREPARING_TTL_SECONDS))
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
)
|
|
|
|
|
|
def _path(eng_dir: str | Path) -> Path:
|
|
return state.resolve_eng_dir(eng_dir) / "state" / _FILE_NAME
|
|
|
|
|
|
def _empty(session_id: str = "", generation: int = 0) -> dict[str, Any]:
|
|
return {
|
|
"schema_version": _SCHEMA_VERSION,
|
|
"context": {"session_id": session_id, "generation": generation},
|
|
"deliveries": {},
|
|
"bindings": {},
|
|
}
|
|
|
|
|
|
def _load(path: Path) -> tuple[dict[str, Any], bool]:
|
|
"""Load valid receipt state; recover malformed/old documents fail-closed."""
|
|
|
|
if not path.exists():
|
|
return _empty(), False
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return _empty(), True
|
|
if not isinstance(raw, dict) or raw.get("schema_version") != _SCHEMA_VERSION:
|
|
return _empty(), True
|
|
if not isinstance(raw.get("context"), dict) or not isinstance(raw.get("deliveries"), dict):
|
|
return _empty(), True
|
|
raw.setdefault("bindings", {})
|
|
return raw, False
|
|
|
|
|
|
def _mutate(eng_dir: str | Path, mutation: Callable[[dict[str, Any]], Any]) -> Any:
|
|
path = _path(eng_dir)
|
|
state.ensure_dir(path.parent)
|
|
with state.lock_file(path):
|
|
data, recovered = _load(path)
|
|
if recovered:
|
|
data["recovered_at"] = _now()
|
|
result = mutation(data)
|
|
state.atomic_json(path, data)
|
|
return result
|
|
|
|
|
|
def _context(data: dict[str, Any], session_id: str) -> tuple[str, int]:
|
|
context = data.setdefault("context", {"session_id": "", "generation": 0})
|
|
recorded = str(context.get("session_id") or "")
|
|
if recorded and recorded != session_id:
|
|
# A new Hermes session is a new context, never proof of the old load.
|
|
context["session_id"] = session_id
|
|
context["generation"] = int(context.get("generation") or 0) + 1
|
|
elif not recorded:
|
|
context["session_id"] = session_id
|
|
return str(context["session_id"]), int(context.get("generation") or 0)
|
|
|
|
|
|
def _delivery_key(session_id: str, generation: int, skill: str, bundle_digest: str) -> str:
|
|
return _digest(f"{session_id}\0{generation}\0{skill}\0{bundle_digest}")
|
|
|
|
|
|
def _prune(data: dict[str, Any]) -> None:
|
|
deliveries = data["deliveries"]
|
|
if len(deliveries) <= _MAX_DELIVERIES:
|
|
return
|
|
bound = {str(binding.get("delivery_id") or "") for binding in data["bindings"].values()}
|
|
removable = sorted(
|
|
(entry for entry in deliveries.values() if entry.get("id") not in bound),
|
|
key=lambda entry: str(entry.get("updated_at") or ""),
|
|
)
|
|
for entry in removable[: max(0, len(deliveries) - _MAX_DELIVERIES)]:
|
|
deliveries.pop(entry["id"], None)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeliveryReservation:
|
|
id: str
|
|
status: str
|
|
skill: str
|
|
bundle_digest: str
|
|
session_id: str
|
|
context_generation: int
|
|
owner: bool
|
|
owner_token: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkillViewResult:
|
|
ready: bool
|
|
content: str = ""
|
|
error: str = ""
|
|
path: str = ""
|
|
|
|
|
|
class HermesSkillViewAdapter:
|
|
"""Small adapter around Hermes's JSON-returning ``skill_view`` helper."""
|
|
|
|
def __init__(self, view: Callable[..., str] | None = None):
|
|
self._view = view
|
|
|
|
def view(self, skill: str, task_id: str | None = None) -> SkillViewResult:
|
|
try:
|
|
view = self._view
|
|
if view is None:
|
|
from tools.skills_tool import skill_view as view # type: ignore[import-not-found]
|
|
raw = view(skill, task_id=task_id)
|
|
payload = json.loads(raw) if isinstance(raw, str) else raw
|
|
if not isinstance(payload, dict) or not payload.get("success"):
|
|
return SkillViewResult(
|
|
False, error=str((payload or {}).get("error") or "skill_view failed")
|
|
)
|
|
content = str(payload.get("content") or "")
|
|
if not content:
|
|
return SkillViewResult(False, error="skill_view returned no skill content")
|
|
return SkillViewResult(True, content=content, path=str(payload.get("path") or ""))
|
|
except Exception as exc: # Hermes availability is an external dependency.
|
|
return SkillViewResult(False, error=f"skill_view unavailable: {exc}")
|
|
|
|
|
|
def prepare_delivery(
|
|
eng_dir: str | Path,
|
|
*,
|
|
session_id: str,
|
|
skill: str,
|
|
bundle_digest: str,
|
|
phase: str,
|
|
vulnerability_class: str | None = None,
|
|
candidate_source: str | None = None,
|
|
) -> DeliveryReservation:
|
|
"""Reserve exactly one delivery for a semantic receipt key.
|
|
|
|
Only the caller receiving ``owner=True`` may invoke Hermes and then call
|
|
:func:`complete_delivery`; concurrent callers see the same ``preparing``
|
|
receipt and never receive duplicate skill content.
|
|
"""
|
|
|
|
if not session_id.strip() or not bundle_digest.startswith("sha256:"):
|
|
raise ValueError("session_id and a sha256 bundle_digest are required")
|
|
policy = validate_skill_selection(skill, phase, vulnerability_class, candidate_source)
|
|
if policy.mismatch_reasons:
|
|
raise ValueError("; ".join(policy.mismatch_reasons))
|
|
|
|
def reserve(data: dict[str, Any]) -> DeliveryReservation:
|
|
current_session, generation = _context(data, session_id.strip())
|
|
identifier = _delivery_key(current_session, generation, skill, bundle_digest)
|
|
existing = data["deliveries"].get(identifier)
|
|
if existing and existing.get("status") == "delivered":
|
|
return DeliveryReservation(
|
|
identifier,
|
|
existing["status"],
|
|
skill,
|
|
bundle_digest,
|
|
current_session,
|
|
generation,
|
|
False,
|
|
)
|
|
if existing and existing.get("status") == "preparing" and not _preparing_expired(existing):
|
|
return DeliveryReservation(
|
|
identifier,
|
|
existing["status"],
|
|
skill,
|
|
bundle_digest,
|
|
current_session,
|
|
generation,
|
|
False,
|
|
)
|
|
owner_token = uuid.uuid4().hex
|
|
now = _now()
|
|
data["deliveries"][identifier] = {
|
|
"id": identifier,
|
|
"skill": skill,
|
|
"bundle_digest": bundle_digest,
|
|
"session_id": current_session,
|
|
"context_generation": generation,
|
|
"status": "preparing",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"expires_at": _preparing_expires_at(),
|
|
"owner_token": owner_token,
|
|
"attempts": int((existing or {}).get("attempts") or 0) + 1,
|
|
}
|
|
_prune(data)
|
|
return DeliveryReservation(
|
|
identifier,
|
|
"preparing",
|
|
skill,
|
|
bundle_digest,
|
|
current_session,
|
|
generation,
|
|
True,
|
|
owner_token,
|
|
)
|
|
|
|
return _mutate(eng_dir, reserve)
|
|
|
|
|
|
def complete_delivery(
|
|
eng_dir: str | Path,
|
|
reservation: DeliveryReservation,
|
|
result: SkillViewResult,
|
|
*,
|
|
delivered_turn_id: str | None = None,
|
|
) -> DeliveryReservation:
|
|
"""Complete a reservation after an actual Hermes ``skill_view`` call."""
|
|
|
|
def complete(data: dict[str, Any]) -> DeliveryReservation:
|
|
entry = data["deliveries"].get(reservation.id)
|
|
if not entry or entry.get("status") != "preparing":
|
|
raise ValueError("delivery reservation is no longer active")
|
|
if not reservation.owner or not reservation.owner_token:
|
|
raise ValueError("only the reservation owner may complete delivery")
|
|
if entry.get("owner_token") != reservation.owner_token:
|
|
raise ValueError("delivery reservation owner is stale; prepare a new delivery")
|
|
entry["status"] = "delivered" if result.ready else "failed"
|
|
entry["updated_at"] = _now()
|
|
entry["delivered_turn_id"] = delivered_turn_id if result.ready else None
|
|
entry["content_digest"] = _digest(result.content) if result.ready else None
|
|
entry["error"] = result.error if not result.ready else None
|
|
data["deliveries"][reservation.id] = entry
|
|
return DeliveryReservation(
|
|
reservation.id,
|
|
entry["status"],
|
|
reservation.skill,
|
|
reservation.bundle_digest,
|
|
reservation.session_id,
|
|
reservation.context_generation,
|
|
False,
|
|
)
|
|
|
|
return _mutate(eng_dir, complete)
|
|
|
|
|
|
def get_delivery(eng_dir: str | Path, delivery_id: str) -> dict[str, Any] | None:
|
|
data, _ = _load(_path(eng_dir))
|
|
return data["deliveries"].get(delivery_id)
|
|
|
|
|
|
def advance_context_generation(eng_dir: str | Path, session_id: str) -> int:
|
|
"""Invalidate active bindings after Hermes context reset/compression."""
|
|
|
|
def advance(data: dict[str, Any]) -> int:
|
|
_context(data, session_id.strip())
|
|
data["context"]["generation"] = int(data["context"].get("generation") or 0) + 1
|
|
return data["context"]["generation"]
|
|
|
|
return _mutate(eng_dir, advance)
|
|
|
|
|
|
def bind_task(
|
|
eng_dir: str | Path,
|
|
*,
|
|
task_id: str,
|
|
delivery_id: str,
|
|
hypothesis_id: str | None = None,
|
|
technique: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Bind a delivered receipt to one task/hypothesis for later enforcement."""
|
|
|
|
def bind(data: dict[str, Any]) -> dict[str, Any]:
|
|
delivery = data["deliveries"].get(delivery_id)
|
|
if not delivery or delivery.get("status") != "delivered":
|
|
raise ValueError("a delivered skill receipt is required before binding")
|
|
binding = {
|
|
"task_id": task_id,
|
|
"delivery_id": delivery_id,
|
|
"skill": delivery["skill"],
|
|
"bundle_digest": delivery["bundle_digest"],
|
|
"session_id": delivery["session_id"],
|
|
"context_generation": delivery["context_generation"],
|
|
"hypothesis_id": hypothesis_id or "",
|
|
"technique": technique.strip(),
|
|
"bound_at": _now(),
|
|
}
|
|
data["bindings"][task_id] = binding
|
|
return binding
|
|
|
|
return _mutate(eng_dir, bind)
|
|
|
|
|
|
def get_binding(eng_dir: str | Path, task_id: str) -> dict[str, Any] | None:
|
|
data, _ = _load(_path(eng_dir))
|
|
return data["bindings"].get(task_id)
|
|
|
|
|
|
def binding_readiness(
|
|
eng_dir: str | Path, *, task_id: str, session_id: str
|
|
) -> tuple[dict[str, Any] | None, str | None]:
|
|
"""Return the active receipt binding, or a precise fail-closed reason."""
|
|
|
|
data, recovered = _load(_path(eng_dir))
|
|
if recovered:
|
|
return None, "skill receipt state is unavailable; prepare the selected skill again"
|
|
context = data.get("context") or {}
|
|
binding = (data.get("bindings") or {}).get(task_id)
|
|
if not binding:
|
|
return None, "the active PTT task has no delivered skill binding"
|
|
if str(context.get("session_id") or "") != session_id:
|
|
return None, "the skill binding belongs to a different session"
|
|
if int(binding.get("context_generation", -1)) != int(context.get("generation") or 0):
|
|
return None, "the skill binding is stale after a context reset"
|
|
delivery = (data.get("deliveries") or {}).get(binding.get("delivery_id"))
|
|
if not delivery or delivery.get("status") != "delivered":
|
|
return None, "the bound skill delivery is not ready"
|
|
if delivery.get("bundle_digest") != binding.get("bundle_digest"):
|
|
return None, "the bound skill digest no longer matches its delivery"
|
|
return {
|
|
**binding,
|
|
"delivered_turn_id": delivery.get("delivered_turn_id"),
|
|
"delivered_api_request_id": delivery.get("delivered_api_request_id"),
|
|
}, None
|
|
|
|
|
|
def record_delivery_turn(
|
|
eng_dir: str | Path,
|
|
*,
|
|
delivery_id: str,
|
|
turn_id: str,
|
|
api_request_id: str = "",
|
|
) -> None:
|
|
"""Associate a successful ``skill_view`` result with its model call."""
|
|
|
|
if not turn_id and not api_request_id:
|
|
return
|
|
|
|
def record(data: dict[str, Any]) -> None:
|
|
entry = data["deliveries"].get(delivery_id)
|
|
if entry and entry.get("status") == "delivered":
|
|
entry["delivered_turn_id"] = turn_id
|
|
entry["delivered_api_request_id"] = api_request_id
|
|
entry["updated_at"] = _now()
|
|
|
|
_mutate(eng_dir, record)
|
|
|
|
|
|
def record_binding_turn(
|
|
eng_dir: str | Path,
|
|
*,
|
|
task_id: str,
|
|
turn_id: str,
|
|
api_request_id: str = "",
|
|
) -> None:
|
|
"""Associate a binding commit with its model call."""
|
|
|
|
if not turn_id and not api_request_id:
|
|
return
|
|
|
|
def record(data: dict[str, Any]) -> None:
|
|
binding = data["bindings"].get(task_id)
|
|
if binding:
|
|
binding["bound_turn_id"] = turn_id
|
|
binding["bound_api_request_id"] = api_request_id
|
|
binding["bound_at"] = _now()
|
|
|
|
_mutate(eng_dir, record)
|