154 lines
4.9 KiB
Python
154 lines
4.9 KiB
Python
"""Tenant-scoped Hermes runtime contracts."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from typing import Any, Mapping
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _required(value: str, label: str) -> str:
|
|
normalized = str(value or "").strip()
|
|
if not normalized:
|
|
raise ValueError(f"{label} is required")
|
|
return normalized
|
|
|
|
|
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
|
|
|
|
|
class HermesJobState(str, Enum):
|
|
QUEUED = "QUEUED"
|
|
CLAIMED = "CLAIMED"
|
|
RUNNING = "RUNNING"
|
|
COMPLETED = "COMPLETED"
|
|
FAILED = "FAILED"
|
|
CANCELLED = "CANCELLED"
|
|
|
|
|
|
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
|
|
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
|
|
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
|
|
HermesJobState.RUNNING: frozenset(
|
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
|
),
|
|
HermesJobState.COMPLETED: frozenset(),
|
|
HermesJobState.FAILED: frozenset(),
|
|
HermesJobState.CANCELLED: frozenset(),
|
|
}
|
|
TERMINAL_STATES = frozenset(
|
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HermesJob:
|
|
job_id: str
|
|
tenant_id: str
|
|
cell_id: str
|
|
profile_id: str
|
|
task_id: str
|
|
trace_id: str
|
|
action: str
|
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
|
idempotency_key: str = ""
|
|
priority: int = 100
|
|
state: HermesJobState = HermesJobState.QUEUED
|
|
revision: int = 1
|
|
assigned_worker_id: str | None = None
|
|
lease_expires_at: str | None = None
|
|
created_at: str = field(default_factory=utc_now)
|
|
updated_at: str = field(default_factory=utc_now)
|
|
|
|
def __post_init__(self) -> None:
|
|
for name in (
|
|
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
|
|
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
|
|
):
|
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
|
if not 0 <= int(self.priority) <= 1000:
|
|
raise ValueError("priority must be between 0 and 1000")
|
|
if self.revision < 1:
|
|
raise ValueError("revision must be >= 1")
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
cell_id: str,
|
|
profile_id: str,
|
|
task_id: str,
|
|
trace_id: str,
|
|
action: str,
|
|
payload: Mapping[str, Any] | None,
|
|
idempotency_key: str,
|
|
priority: int = 100,
|
|
) -> "HermesJob":
|
|
return cls(
|
|
job_id=f"hermes_{uuid.uuid4().hex}",
|
|
tenant_id=tenant_id,
|
|
cell_id=cell_id,
|
|
profile_id=profile_id,
|
|
task_id=task_id,
|
|
trace_id=trace_id,
|
|
action=action,
|
|
payload=_safe_mapping(payload),
|
|
idempotency_key=idempotency_key,
|
|
priority=priority,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HermesResult:
|
|
job_id: str
|
|
tenant_id: str
|
|
task_id: str
|
|
trace_id: str
|
|
state: HermesJobState
|
|
worker_id: str | None = None
|
|
output: Mapping[str, Any] | None = None
|
|
error: Mapping[str, Any] | None = None
|
|
started_at: str | None = None
|
|
finished_at: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
|
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
|
if self.output is not None:
|
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
|
if self.error is not None:
|
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HermesHealth:
|
|
runtime_id: str
|
|
status: str
|
|
version: str
|
|
heartbeat_at: str
|
|
active_workers: int = 0
|
|
active_jobs: int = 0
|
|
queued_jobs: int = 0
|
|
tenant_partitions: int = 0
|
|
profile_isolation_enforced: bool = True
|
|
details: Mapping[str, Any] = field(default_factory=dict)
|
|
|
|
def __post_init__(self) -> None:
|
|
for name in ("runtime_id", "status", "version", "heartbeat_at"):
|
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
|
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
|
|
if int(getattr(self, name)) < 0:
|
|
raise ValueError(f"{name} must be >= 0")
|
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|