"""Typed occurrence-history interface with idempotent reconciliation (R4/F4). Both the poll loop and startup recovery record through :class:`HistoryRecorder`, so completed/failed/interrupted entries share one shape and one idempotency identity: ``(run_id, kind, checkpoint_id)``. A resumed run that interrupts again produces a new checkpoint id and therefore a new entry; repeating recovery never duplicates an entry. The file-backed recorder derives entry identity the same way the scheduler always has (occurrence instants hash to ``occurrence_id``; interval summaries use their span key). """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Protocol from wf_scheduling.models import OccurrenceKind, OccurrenceRecord from wf_scheduling.occurrences import occurrence_id UTC = timezone.utc TERMINAL_KINDS: tuple[str, str, str] = ("completed", "interrupted", "failed") @dataclass(frozen=True) class HistoryEntry: """One occurrence-history entry with a stable idempotency identity.""" schedule_id: str kind: OccurrenceKind resolved_at: datetime | None = None run_id: str | None = None revision: int | None = None reason: str = "" admitted_at: datetime | None = None started_at: datetime | None = None checkpoint_id: str | None = None interval_start: datetime | None = None interval_end: datetime | None = None interval_count: int = 0 created_at: datetime | None = None class HistoryRecorder(Protocol): """Occurrence-history sink shared by polling and recovery.""" def record(self, entry: HistoryEntry) -> None: """Append one entry (callers dedup stopped results first).""" ... def has_terminal( self, schedule_id: str, run_id: str, kind: str, checkpoint_id: str | None = None, ) -> bool: """Whether this exact stopped result was already reconciled.""" ... def entry_occurrence_id(entry: HistoryEntry, created: datetime) -> str: """Derive the history occurrence id with the scheduler's standing rules.""" if entry.resolved_at is not None: return occurrence_id(entry.schedule_id, entry.resolved_at) if entry.interval_start is not None and entry.interval_end is not None: start = entry.interval_start.isoformat() end = entry.interval_end.isoformat() return f"{entry.schedule_id}|summary|{start}|{end}" return f"{entry.schedule_id}|summary|{created.isoformat()}" class FileScheduleHistoryRecorder: """History recorder backed by a file schedule store.""" def __init__(self, schedule_store: Any) -> None: self.schedule_store = schedule_store def record(self, entry: HistoryEntry) -> None: created = ( entry.created_at if entry.created_at is not None else datetime.now(UTC) ) self.schedule_store.append_history( OccurrenceRecord( schedule_id=entry.schedule_id, occurrence_id=entry_occurrence_id(entry, created), kind=entry.kind, resolved_at=entry.resolved_at, run_id=entry.run_id, revision=entry.revision, reason=entry.reason, admitted_at=entry.admitted_at, started_at=entry.started_at, checkpoint_id=entry.checkpoint_id, interval_start=entry.interval_start, interval_end=entry.interval_end, interval_count=entry.interval_count, created_at=created, ) ) def has_terminal( self, schedule_id: str, run_id: str, kind: str, checkpoint_id: str | None = None, ) -> bool: return self.schedule_store.has_history_entry( schedule_id, run_id=run_id, kind=kind, checkpoint_id=checkpoint_id, )