import json from typing import Annotated, Any, Literal from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StringConstraints, field_validator, model_validator RunStatus = Literal["running", "success", "warning", "failed"] Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)] MAX_MESSAGE_CHARS = 32_000 MAX_DETAILS_BYTES = 16_000 class Strict(BaseModel): # Reject unknown fields so a typo in a collector script fails loudly instead of being dropped. model_config = ConfigDict(extra="forbid") class JobIn(Strict): external_id: Name = Field(description="The source system's own stable ID for the job") name: Name # Omit to leave the stored value untouched; send null to clear it. next_run_at: AwareDatetime | None = None class RunIn(Strict): external_id: Name | None = Field( None, description="The source's ID for this run. Defaults to started_at, so a 'running' " "report and its later completion report merge into one run.") status: RunStatus started_at: AwareDatetime # Omit for a finished run to use the time the report was received. finished_at: AwareDatetime | None = None message: str = "" size_bytes: int | None = Field(None, ge=0) details: dict[str, Any] = Field(default_factory=dict) @field_validator("message") @classmethod def truncate_message(cls, v: str) -> str: # Backup logs can be huge; keep the head, which is where the summary usually is. return v if len(v) <= MAX_MESSAGE_CHARS else v[:MAX_MESSAGE_CHARS] + "\n… [truncated]" @field_validator("details") @classmethod def limit_details(cls, v: dict[str, Any]) -> dict[str, Any]: if len(json.dumps(v)) > MAX_DETAILS_BYTES: raise ValueError(f"details must serialize to at most {MAX_DETAILS_BYTES} bytes") return v @model_validator(mode="after") def check_times(self) -> "RunIn": if self.status == "running" and self.finished_at is not None: raise ValueError("a running run cannot have finished_at") if self.finished_at is not None and self.finished_at < self.started_at: raise ValueError("finished_at is before started_at") return self class IngestIn(Strict): tenant: Name = Field(description="Company name as shown on the dashboard") source_system: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)] job: JobIn run: RunIn | None = Field(None, description="Omit to only register/update the job (e.g. next_run_at)")