"""Immutable, secret-free records for preprocessing execution.""" from __future__ import annotations import re from datetime import UTC, datetime from pathlib import Path from typing import Literal, Self from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator _JOB_KEY = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") _RUN_ID = re.compile(r"^[0-9a-f]{32}$") JobStatus = Literal["pending", "running", "succeeded", "failed"] StageStatus = Literal["pending", "running", "succeeded", "failed"] def utc_now() -> datetime: return datetime.now(UTC) def _validate_job_key(value: str) -> str: if not _JOB_KEY.fullmatch(value): raise ValueError("job identifiers must be lowercase filesystem-safe keys") return value def _validate_run_id(value: str | None) -> str | None: if value is not None and not _RUN_ID.fullmatch(value): raise ValueError("run id must contain 32 lowercase hexadecimal characters") return value class _FrozenModel(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid", validate_default=True) def model_copy(self, *, update=None, deep: bool = False) -> Self: data = self.model_dump(round_trip=True) if update: data.update(update) return type(self).model_validate(data) @field_serializer("*", when_used="json", check_fields=False) def serialize_utc(self, value): if isinstance(value, datetime): return value.astimezone(UTC).isoformat().replace("+00:00", "Z") return value class JobSpec(_FrozenModel): """Execution input. The local root is deliberately excluded from serialization.""" workspace_id: str job_type: str workspace_root: Path = Field(exclude=True) dry_run: bool = False resume_run_id: str | None = None _workspace_key = field_validator("workspace_id")(_validate_job_key) _job_type_key = field_validator("job_type")(_validate_job_key) _resume_id = field_validator("resume_run_id")(_validate_run_id) def model_copy(self, *, update=None, deep: bool = False) -> Self: data = { "workspace_id": self.workspace_id, "job_type": self.job_type, "workspace_root": self.workspace_root, "dry_run": self.dry_run, "resume_run_id": self.resume_run_id, } if update: data.update(update) return type(self).model_validate(data) def with_resume(self, run_id: str) -> "JobSpec": return self.model_copy(update={"resume_run_id": run_id}) class StageError(_FrozenModel): category: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,127}$") message: Literal["stage execution failed"] = "stage execution failed" class StageRun(_FrozenModel): name: str status: StageStatus = "pending" started_at: datetime | None = None finished_at: datetime | None = None error: StageError | None = None _name_key = field_validator("name")(_validate_job_key) class JobRun(_FrozenModel): """Durable checkpoint, persisted after every state transition.""" schema_version: Literal[1] = 1 run_id: str job_type: str dry_run: bool status: JobStatus started_at: datetime finished_at: datetime | None = None resumed_from: str | None = None stages: tuple[StageRun, ...] = () _run_id = field_validator("run_id")(_validate_run_id) _job_type = field_validator("job_type")(_validate_job_key) _resumed_from = field_validator("resumed_from")(_validate_run_id) class JobReport(JobRun): """Public machine-readable terminal report (contains no paths or stage outputs)."""