"""Resolved input validates against the pinned workflow schema (R4 item 3). Preparation checks more than required key names: the resolved object must satisfy the pinned root artifact's input schema (same snapshot that is captured in the admission) before any run identity is allocated. Invalid input rejects the occurrence without admission, run, or dispatch. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast from tests.artifacts.test_run_store import artifact as _artifact from tests.artifacts.test_run_store import deployment as _deployment from tests.scheduling.controlled import DictDeployments, ScriptedDispatcher from wf_artifacts.runs.models import PinnedRunEnvironment from wf_artifacts.runs.store import FileRunStore from wf_scheduling.calendar import OneShotSource from wf_scheduling.dispatch import StillRunning from wf_scheduling.models import Schedule from wf_scheduling.ownership import SchedulerOwnership from wf_scheduling.poll import Scheduler from wf_scheduling.prepare import 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) STRICT_SCHEMA: dict[str, Any] = { "type": "object", "properties": { "count": {"type": "integer"}, "nested": { "type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"], "additionalProperties": False, }, }, "required": ["count"], "additionalProperties": False, } 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 _strict_env(sched: Any) -> PinnedRunEnvironment: artifact = _artifact().model_copy( update={"input_schema": dict(STRICT_SCHEMA)}, ) deployment = _deployment().model_copy(update={"id": sched.deployment_id}) return PinnedRunEnvironment( deployment=deployment, root_artifact=artifact, child_artifacts=[] ) def _scheduler( tmp_path: Path, ownership: SchedulerOwnership, bindings: list[dict[str, Any]], *, script: dict | None = None, ) -> tuple[Scheduler, FileScheduleStore, FileRunStore, list[str]]: calls: list[str] = [] sched_store = FileScheduleStore(tmp_path / "sched") run_store = FileRunStore(tmp_path / "runs") sched = Scheduler( schedule_store=sched_store, run_store=run_store, sources={}, capacity=4, preparer=SchedulePreparer( DictDeployments({"dep-1": {"rev": 1, "required": []}}), _strict_env, ), dispatcher=ScriptedDispatcher(script), ownership=ownership, ) intended = ts(2026, 9, 8, 12, 0) sched_store.create_schedule(_sched_model("s", input_bindings=bindings)) sched_store.save_consumed("s", intended - timedelta(hours=1)) sched.sources["s"] = OneShotSource(intended) dispatches = cast(ScriptedDispatcher, sched.dispatcher) outcome = (script or {}).get("*", "complete") assert isinstance(outcome, str) def spy(admission: Any, now: datetime) -> Any: calls.append(admission.id) if outcome == "hang": return StillRunning() from wf_scheduling.dispatch import Stopped return Stopped(result=dispatches.finish(admission, outcome)) dispatches.script = {"*": spy} return sched, sched_store, run_store, calls def _rejected_entry(store: FileScheduleStore) -> dict[str, Any]: page = store.list_occurrences("s", limit=100) rows = cast(list[dict[str, Any]], page["occurrences"]) assert len(rows) == 1 assert rows[0]["kind"] == "preflight-rejected" return rows[0] def test_wrong_scalar_type_rejects_without_admission(tmp_path: Path) -> None: ownership = SchedulerOwnership(tmp_path, owner="test").acquire() try: sched, store, runs, calls = _scheduler( tmp_path, ownership, [ { "target": "count", "expression": { "kind": "literal", "value": "definitely not integer", }, } ], ) intended = ts(2026, 9, 8, 12, 0) assert sched.poll(intended) == {"s": "admit:None"} entry = _rejected_entry(store) assert "invalid-input" in entry["reason"] assert runs.list_runs() == [] assert runs.list_admissions() == [] assert calls == [] finally: ownership.release() def test_nested_constraint_violation_rejects(tmp_path: Path) -> None: ownership = SchedulerOwnership(tmp_path, owner="test").acquire() try: sched, store, runs, calls = _scheduler( tmp_path, ownership, [ {"target": "count", "expression": {"kind": "literal", "value": 3}}, { "target": "nested", "expression": { "kind": "object", "fields": {"name": {"kind": "literal", "value": 7}}, }, }, ], ) assert sched.poll(ts(2026, 9, 8, 12, 0)) == {"s": "admit:None"} _rejected_entry(store) assert runs.list_runs() == [] assert calls == [] finally: ownership.release() def test_disallowed_extra_property_rejects(tmp_path: Path) -> None: ownership = SchedulerOwnership(tmp_path, owner="test").acquire() try: sched, store, runs, calls = _scheduler( tmp_path, ownership, [ {"target": "count", "expression": {"kind": "literal", "value": 3}}, { "target": "surprise", "expression": {"kind": "literal", "value": "x"}, }, ], ) assert sched.poll(ts(2026, 9, 8, 12, 0)) == {"s": "admit:None"} _rejected_entry(store) assert runs.list_runs() == [] assert calls == [] finally: ownership.release() def test_valid_structured_input_admits(tmp_path: Path) -> None: ownership = SchedulerOwnership(tmp_path, owner="test").acquire() try: sched, store, runs, calls = _scheduler( tmp_path, ownership, [ {"target": "count", "expression": {"kind": "literal", "value": 3}}, { "target": "nested", "expression": { "kind": "object", "fields": {"name": {"kind": "literal", "value": "ok"}}, }, }, ], script={"*": "hang"}, ) intended = ts(2026, 9, 8, 12, 0) result = sched.poll(intended) assert result["s"].startswith("admit:run-") run_id = result["s"].split(":", 1)[1] assert runs.get_admission(run_id).resolved_input == { "count": 3, "nested": {"name": "ok"}, } assert len(calls) == 1 finally: ownership.release() def test_changed_contract_rejects_before_admission(tmp_path: Path) -> None: ownership = SchedulerOwnership(tmp_path, owner="test").acquire() try: sched, store, runs, calls = _scheduler( tmp_path, ownership, [ {"target": "count", "expression": {"kind": "literal", "value": 3}}, ], ) # The pinned contract now additionally requires a token the # schedule does not provide. strict = dict(STRICT_SCHEMA) strict["required"] = ["count", "token"] artifact = _artifact().model_copy(update={"input_schema": strict}) def changed_env(sched: Any) -> PinnedRunEnvironment: deployment = _deployment().model_copy(update={"id": sched.deployment_id}) return PinnedRunEnvironment( deployment=deployment, root_artifact=artifact, child_artifacts=[] ) sched.preparer = SchedulePreparer( DictDeployments({"dep-1": {"rev": 2, "required": []}}), changed_env ) assert sched.poll(ts(2026, 9, 8, 12, 0)) == {"s": "admit:None"} _rejected_entry(store) assert runs.list_runs() == [] assert runs.list_admissions() == [] assert calls == [] finally: ownership.release() def test_environment_built_once_per_preparation(tmp_path: Path) -> None: ownership = SchedulerOwnership(tmp_path, owner="test").acquire() try: builds: list[str] = [] def counting_env(sched: Any) -> PinnedRunEnvironment: builds.append(sched.id) return _strict_env(sched) sched_store = FileScheduleStore(tmp_path / "sched") run_store = FileRunStore(tmp_path / "runs") sched = Scheduler( schedule_store=sched_store, run_store=run_store, sources={}, capacity=4, preparer=SchedulePreparer( DictDeployments({"dep-1": {"rev": 1, "required": []}}), counting_env ), dispatcher=ScriptedDispatcher({"*": "hang"}), ownership=ownership, ) intended = ts(2026, 9, 8, 12, 0) sched_store.create_schedule( _sched_model( "s", input_bindings=[ {"target": "count", "expression": {"kind": "literal", "value": 1}} ], ) ) sched_store.save_consumed("s", intended - timedelta(hours=1)) sched.sources["s"] = OneShotSource(intended) sched.poll(intended) assert builds == ["s"] finally: ownership.release()