"""Typed invocation preparation over real schedule bindings (R4/F6). The scheduler no longer hardcodes fixture input: ``SchedulePreparer`` resolves the schedule's own ``input_bindings`` against the occurrence environment through the shared schedule-expression contract, rechecks the deployment directory, and reports preflight rejections without inventing a run. """ from __future__ import annotations import inspect import typing from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast from tests.scheduling.controlled import ( DictDeployments, ScriptedDispatcher, fixture_environment, ) from wf_artifacts.runs.store import FileRunStore from wf_scheduling.calendar import OneShotSource from wf_scheduling.dispatch import RunDispatcher from wf_scheduling.models import Schedule from wf_scheduling.ownership import SchedulerOwnership from wf_scheduling.poll import Scheduler from wf_scheduling.prepare import ( InvocationPreparer, PreparationRejected, SchedulePreparer, ) from wf_scheduling.store import FileScheduleStore def ts(y: int, mo: int, d: int, h: int = 0, mi: int = 0) -> datetime: return datetime(y, mo, d, h, mi, tzinfo=UTC) def _sched_model(sid: str, **kw: Any) -> Schedule: now = ts(2026, 9, 8, 12, 0) base: dict[str, Any] = { "id": sid, "deployment_id": "dep-1", "trigger": {"kind": "cron", "expression": "0 * * * *", "timezone": "UTC"}, "input_bindings": [], "created_at": now.isoformat(), "updated_at": now.isoformat(), } base.update(kw) return Schedule.model_validate(base) def _scheduler( tmp_path: Path, script: dict | None = None ) -> tuple[Scheduler, FileScheduleStore, FileRunStore]: sched_store = FileScheduleStore(tmp_path / "sched") run_store = FileRunStore(tmp_path / "runs") def revisioned_fixture_environment(sched: Any) -> Any: environment = fixture_environment(sched) deployment = environment.deployment.model_copy(update={"revision": 3}) return environment.model_copy(update={"deployment": deployment}) preparer = SchedulePreparer( DictDeployments({"dep-1": {"rev": 3, "required": []}}), revisioned_fixture_environment, ) sched = Scheduler( schedule_store=sched_store, run_store=run_store, sources={}, capacity=4, preparer=preparer, dispatcher=ScriptedDispatcher(script), ownership=SchedulerOwnership(tmp_path, owner="test").acquire(), ) return sched, sched_store, run_store def test_scheduler_requires_typed_collaborators() -> None: params = inspect.signature(Scheduler.__init__).parameters assert set(params) == { "self", "schedule_store", "run_store", "sources", "capacity", "preparer", "dispatcher", "ownership", "history", } def test_production_scheduler_has_no_fixture_seams() -> None: import wf_scheduling.poll as poll_module source = inspect.getsource(poll_module) assert "_test_environment" not in source assert "self.outcomes" not in source assert "self.deployments" not in source assert "FixturePreparer" not in source assert '"eng"' not in source and "'eng'" not in source # Collaborators are protocols consumed here, implemented elsewhere. assert "InvocationPreparer" in source assert "RunDispatcher" in source hints = typing.get_type_hints(poll_module.Scheduler.__init__) assert hints["preparer"] is InvocationPreparer assert hints["dispatcher"] is RunDispatcher def test_preparer_contract_is_a_protocol() -> None: assert typing.is_protocol(InvocationPreparer) assert typing.is_protocol(RunDispatcher) def test_occurrence_bindings_resolve_into_admission(tmp_path: Path) -> None: sched, store, runs = _scheduler(tmp_path, script={"*": "hang"}) try: intended = ts(2026, 9, 8, 12, 0) model = _sched_model( "b", input_bindings=[ { "target": "team", "expression": {"kind": "literal", "value": "engineering"}, }, { "target": "report_time", "expression": { "kind": "occurrence", "field": "scheduled_at", }, }, { "target": "which", "expression": {"kind": "occurrence", "field": "schedule_id"}, }, ], ) store.create_schedule(model) store.save_consumed("b", intended - timedelta(hours=1)) sched.sources["b"] = OneShotSource(intended) result = sched.poll(intended) assert result["b"].startswith("admit:run-") run_id = result["b"].split(":", 1)[1] admission = runs.get_admission(run_id) assert admission.resolved_input["team"] == "engineering" assert admission.resolved_input["report_time"] == intended.isoformat() assert admission.resolved_input["which"] == "b" assert admission.deployment_revision == 3 assert admission.schedule_revision == 1 finally: sched.ownership.release() def test_unknown_deployment_rejects_without_a_run(tmp_path: Path) -> None: sched, store, runs = _scheduler(tmp_path) try: intended = ts(2026, 9, 8, 12, 0) store.create_schedule(_sched_model("gone", deployment_id="dep-missing")) store.save_consumed("gone", intended - timedelta(hours=1)) sched.sources["gone"] = OneShotSource(intended) assert sched.poll(intended) == {"gone": "admit:None"} assert runs.list_runs() == [] assert runs.list_admissions() == [] page = store.list_occurrences("gone", limit=100) kinds = [r["kind"] for r in cast(list[dict[str, Any]], page["occurrences"])] assert kinds == ["preflight-rejected"] finally: sched.ownership.release() def test_deployment_revision_mismatch_rejects_pinned_environment() -> None: """Preparation fails when directory metadata no longer matches the pin.""" preparer = SchedulePreparer( DictDeployments({"dep-1": {"rev": 2, "required": []}}), fixture_environment, ) result = preparer.prepare( sched=_sched_model("s"), intended=ts(2026, 9, 8, 12, 0), now=ts(2026, 9, 8, 12, 0), ) assert isinstance(result, PreparationRejected) assert result.reason == "deployment-changed" def test_missing_required_input_rejects_without_a_run(tmp_path: Path) -> None: sched_store = FileScheduleStore(tmp_path / "sched") run_store = FileRunStore(tmp_path / "runs") preparer = SchedulePreparer( DictDeployments({"dep-1": {"rev": 1, "required": ["token"]}}), fixture_environment, ) sched = Scheduler( schedule_store=sched_store, run_store=run_store, sources={}, capacity=4, preparer=preparer, dispatcher=ScriptedDispatcher({"*": "hang"}), ownership=SchedulerOwnership(tmp_path, owner="test").acquire(), ) try: intended = ts(2026, 9, 8, 12, 0) sched_store.create_schedule(_sched_model("need")) sched_store.save_consumed("need", intended - timedelta(hours=1)) sched.sources["need"] = OneShotSource(intended) assert sched.poll(intended) == {"need": "admit:None"} assert run_store.list_runs() == [] page = sched_store.list_occurrences("need", limit=100) entry = cast(list[dict[str, Any]], page["occurrences"])[0] assert entry["kind"] == "preflight-rejected" assert "missing-input" in entry["reason"] finally: sched.ownership.release() def test_conflicting_schedule_targets_reject_without_a_run(tmp_path: Path) -> None: sched, store, runs = _scheduler(tmp_path) try: intended = ts(2026, 9, 8, 12, 0) store.create_schedule( _sched_model( "conflict", input_bindings=[ { "target": "a", "expression": {"kind": "literal", "value": 1}, }, { "target": "a.b", "expression": {"kind": "literal", "value": 2}, }, ], ) ) store.save_consumed("conflict", intended - timedelta(hours=1)) sched.sources["conflict"] = OneShotSource(intended) assert sched.poll(intended) == {"conflict": "admit:None"} assert runs.list_runs() == [] page = store.list_occurrences("conflict", limit=100) entry = cast(list[dict[str, Any]], page["occurrences"])[0] assert entry["kind"] == "preflight-rejected" assert entry["reason"].startswith("invalid-input:") finally: sched.ownership.release() def test_preparer_rejection_type_shape() -> None: import dataclasses rejected = PreparationRejected(reason="deployment-deleted") assert rejected.reason == "deployment-deleted" assert dataclasses.is_dataclass(PreparationRejected) def test_vanished_deployment_in_environment_build_is_preflight() -> None: def boom(sched: Any) -> Any: raise KeyError("dep-1") preparer = SchedulePreparer( DictDeployments({"dep-1": {"rev": 3, "required": []}}), boom, ) intended = ts(2026, 9, 8, 12, 0) result = preparer.prepare( sched=_sched_model("gone"), intended=intended, now=intended ) assert isinstance(result, PreparationRejected) assert result.reason == "deployment-deleted"