"""T13 schedule JSON-RPC surface tests (transport + client mixin). Uses the green ``httpx2`` ASGI pattern from ``test_client.py`` (the ``httpx``-based ``test_app.py`` cannot even be collected in this env). History and held-candidate fixtures go through the public file schedule store at the same root, never through server privates. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any import httpx2 import pytest from pydantic import ValidationError from wf_api.models import RawWorkflowPlan from wf_api.surface import WorkflowScheduleSurface from wf_core import END from wf_scheduling.history import FileScheduleHistoryRecorder, HistoryEntry from wf_scheduling.models import PendingCandidate from wf_scheduling.store import FileScheduleStore from wf_server import build_local_static_workflow_server from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app from wf_transport_rpc_http.client.base import RpcProtocolError from wf_transport_rpc_http.client.schedules import RpcScheduleClientMixin from wf_transport_rpc_http.models import ( CreateScheduleParams, ListOccurrencesParams, UpdateScheduleParams, ) def _cron() -> dict[str, Any]: return {"kind": "cron", "expression": "* * * * *", "timezone": "UTC"} def _constant_plan() -> RawWorkflowPlan: return RawWorkflowPlan.model_validate( { "name": "sched_constant", "input_schema": {"type": "object", "properties": {}}, "state_schema": { "type": "object", "properties": { "result": {"type": "string", "reducer": "wf.std.replace"} }, }, "output_schema": { "type": "object", "properties": {"result": {"type": "string"}}, "required": ["result"], }, "outcomes": ["ok"], "start": "constant", "nodes": [ { "id": "constant", "type": "node", "node": "wf.std.constant", "input": [ { "value": "hello from schedule", "target": {"root": "local", "parts": ["value"]}, } ], "output": [ { "source": {"root": "local", "parts": ["value"]}, "target": {"root": "state", "parts": ["result"]}, } ], } ], "edges": [{"from": "constant", "outcome": "ok", "to": END}], "output": [ { "path": {"root": "state", "parts": ["result"]}, "target": {"root": "local", "parts": ["result"]}, } ], } ) async def _seed_server(tmp_path: Any, name: str = "sched-art") -> Any: """Build a schedule-enabled server with one artifact + deployment.""" _ = name server = build_local_static_workflow_server(tmp_path / "store", schedules=True) await server.api.create_artifact_from_plan( artifact_id="sched-art", version=1, title="Sched Art", plan=_constant_plan(), outcomes=["ok"], source_bindings={}, ) await server.api.save_deployment( { "id": "dep.personal", "artifact_id": "sched-art", "artifact_version": 1, "bindings": {}, } ) return server def _client_for(server: Any) -> tuple[RpcWorkflowApiClient, httpx2.AsyncClient]: """Return an RPC client bound to the server over ASGI transport.""" app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) http_client = httpx2.AsyncClient(transport=transport, base_url="http://test") return ( RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ), http_client, ) async def test_rpc_schedule_crud_lifecycle(tmp_path) -> None: server = await _seed_server(tmp_path) client, http_client = _client_for(server) async with http_client: created = await client.create_schedule( schedule_id="s", deployment_id="dep.personal", trigger=_cron(), ) assert created["id"] == "s" assert created["deployment_id"] == "dep.personal" assert created["trigger"] == _cron() assert created["input_bindings"] == [] assert created["overlap"] == "skip" assert created["misfire"] == "skip" assert created["max_active_runs"] == 1 assert created["lateness_allowance_s"] == 60.0 assert created["max_steps"] is None assert created["revision"] == 1 assert created["enabled"] is True assert created["paused"] is False assert created["deleted"] is False fetched = await client.get_schedule(schedule_id="s") assert fetched["id"] == "s" assert fetched["revision"] == 1 listed = await client.list_schedules() assert [row["id"] for row in listed["schedules"]] == ["s"] updated = await client.update_schedule( schedule_id="s", expected_revision=1, max_active_runs=3, ) assert updated["revision"] == 2 assert updated["max_active_runs"] == 3 paused = await client.pause_schedule(schedule_id="s") assert paused["paused"] is True resumed = await client.resume_schedule(schedule_id="s") assert resumed["paused"] is False deleted = await client.delete_schedule(schedule_id="s") assert deleted["deleted"] is True assert (await client.list_schedules())["schedules"] == [] listed_deleted = await client.list_schedules(include_deleted=True) assert [row["id"] for row in listed_deleted["schedules"]] == ["s"] async def test_rpc_schedule_update_rejects_stale_revision(tmp_path) -> None: server = await _seed_server(tmp_path) client, http_client = _client_for(server) async with http_client: await client.create_schedule( schedule_id="s", deployment_id="dep.personal", trigger=_cron(), ) updated = await client.update_schedule( schedule_id="s", expected_revision=1, overlap="parallel", ) assert updated["revision"] == 2 with pytest.raises(RpcProtocolError) as raised: await client.update_schedule( schedule_id="s", expected_revision=1, overlap="skip", ) assert raised.value.code == 5000 assert isinstance(raised.value.data, dict) assert raised.value.data["code"] == "StaleScheduleRevisionError" async def test_rpc_schedule_occurrences_pagination_and_pending(tmp_path) -> None: server = await _seed_server(tmp_path) client, http_client = _client_for(server) store = FileScheduleStore(tmp_path / "store") base = datetime(2026, 9, 8, 12, 0, tzinfo=UTC) async with http_client: await client.create_schedule( schedule_id="s", deployment_id="dep.personal", trigger=_cron(), ) recorder = FileScheduleHistoryRecorder(store) for index in range(3): instant = base + timedelta(minutes=index) recorder.record( HistoryEntry( schedule_id="s", kind="admitted", resolved_at=instant, run_id=f"run-{index}", revision=1, reason="rev=1", created_at=instant, ) ) intended = base + timedelta(hours=1) store.save_candidate( PendingCandidate(schedule_id="s", intended_at=intended, revision=1), schedule_id="s", ) first = await client.list_schedule_occurrences(schedule_id="s", limit=2) assert first["total"] == 4 assert first["cursor"] is None assert first["limit"] == 2 # The pending synthesis prepends one row to the stored page. assert len(first["occurrences"]) == 3 pending = first["occurrences"][0] assert pending["kind"] == "pending" assert pending["schedule_id"] == "s" assert pending["revision"] == 1 assert pending["run_id"] is None assert first["occurrences"][1]["kind"] == "admitted" assert first["next_cursor"] is not None # The legacy first-page offset also carries the pending synthesis. legacy_first = await client.list_schedule_occurrences( schedule_id="s", cursor="0", limit=2 ) assert legacy_first["total"] == 4 assert legacy_first["occurrences"][0]["kind"] == "pending" later = await client.list_schedule_occurrences( schedule_id="s", cursor=first["next_cursor"], limit=2 ) assert later["total"] == 3 assert all(row["kind"] != "pending" for row in later["occurrences"]) store.save_candidate(None, schedule_id="s") plain = await client.list_schedule_occurrences(schedule_id="s", limit=10) assert plain["total"] == 3 assert all(row["kind"] != "pending" for row in plain["occurrences"]) async def test_rpc_schedule_error_mapping(tmp_path) -> None: server = await _seed_server(tmp_path) client, http_client = _client_for(server) async with http_client: with pytest.raises(RpcProtocolError) as unknown: await client.get_schedule(schedule_id="missing") # Trigger shapes fail Schedule model validation inside the service, # which maps to InvalidParams rather than a workflow error. with pytest.raises(RpcProtocolError) as bad_trigger: await client.create_schedule( schedule_id="bad", deployment_id="dep.personal", trigger={"kind": "hourly"}, ) with pytest.raises(RpcProtocolError) as bad_deployment: await client.create_schedule( schedule_id="bad-dep", deployment_id="missing.dep", trigger=_cron(), ) assert unknown.value.code == 5000 assert isinstance(unknown.value.data, dict) assert unknown.value.data["code"] == "ScheduleNotFoundError" assert bad_trigger.value.code == -32602 assert bad_deployment.value.code == 5000 assert isinstance(bad_deployment.value.data, dict) assert bad_deployment.value.data["code"] == "KeyError" async def test_rpc_schedule_without_store_maps_keyerror(tmp_path) -> None: # A server built without schedules=True has no schedule store; the call # must surface as a workflow RPC error, not a new gate or a crash. server = build_local_static_workflow_server(tmp_path / "store") client, http_client = _client_for(server) async with http_client: with pytest.raises(RpcProtocolError) as raised: await client.get_schedule(schedule_id="s") assert raised.value.code == 5000 assert isinstance(raised.value.data, dict) assert raised.value.data["code"] == "KeyError" async def test_rpc_schedule_raw_envelope_reports_workflow_error(tmp_path) -> None: server = await _seed_server(tmp_path) app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: response = await http_client.post( "http://test/rpc", json={ "jsonrpc": "2.0", "id": "sched-1", "method": "workflow.schedules.get", "params": {"schedule_id": "missing"}, }, ) payload = response.json() assert response.status_code == 200 assert payload["error"]["code"] == 5000 assert payload["error"]["data"]["code"] == "ScheduleNotFoundError" def test_schedule_params_reject_invalid_envelopes() -> None: with pytest.raises(ValidationError): CreateScheduleParams.model_validate( {"schedule_id": "", "deployment_id": "dep.personal", "trigger": _cron()} ) with pytest.raises(ValidationError): CreateScheduleParams.model_validate( { "schedule_id": "s", "deployment_id": "dep.personal", "trigger": _cron(), "max_active_runs": 0, } ) with pytest.raises(ValidationError): CreateScheduleParams.model_validate( { "schedule_id": "s", "deployment_id": "dep.personal", "trigger": _cron(), "max_steps": 0, } ) # Strict budgets reject stringly numbers instead of coercing them. with pytest.raises(ValidationError): CreateScheduleParams.model_validate( { "schedule_id": "s", "deployment_id": "dep.personal", "trigger": _cron(), "max_steps": "5", } ) with pytest.raises(ValidationError): ListOccurrencesParams.model_validate({"schedule_id": "s", "limit": 0}) with pytest.raises(ValidationError): ListOccurrencesParams.model_validate({"schedule_id": "s", "limit": 101}) # Misspelled params are rejected early (extra=forbid). with pytest.raises(ValidationError): CreateScheduleParams.model_validate( { "schedule_id": "s", "deployment_id": "dep.personal", "trigger": _cron(), "schedul_id": "typo", } ) params = CreateScheduleParams.model_validate( {"schedule_id": "s", "deployment_id": "dep.personal", "trigger": _cron()} ) assert params.input_bindings == [] assert params.overlap == "skip" assert params.misfire == "skip" assert params.max_active_runs == 1 assert params.lateness_allowance_s == 60.0 assert params.max_steps is None assert params.enabled is True update = UpdateScheduleParams.model_validate( {"schedule_id": "s", "expected_revision": 1} ) assert update.deployment_id is None assert update.trigger is None assert update.input_bindings is None assert update.max_steps is None assert update.enabled is None occurrences = ListOccurrencesParams.model_validate({"schedule_id": "s"}) assert occurrences.cursor is None assert occurrences.limit == 50 async def test_rpc_schedule_client_sends_exact_payloads() -> None: calls: list[dict[str, Any]] = [] class Client(RpcScheduleClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"id": "s", "revision": 1} client = Client() await client.create_schedule( schedule_id="s", deployment_id="dep.personal", trigger=_cron(), ) await client.get_schedule(schedule_id="s") await client.list_schedules() await client.update_schedule( schedule_id="s", expected_revision=1, max_active_runs=3 ) await client.pause_schedule(schedule_id="s") await client.resume_schedule(schedule_id="s") await client.delete_schedule(schedule_id="s") await client.list_schedule_occurrences(schedule_id="s", limit=2) assert [call["method"] for call in calls] == [ "workflow.schedules.create", "workflow.schedules.get", "workflow.schedules.list", "workflow.schedules.update", "workflow.schedules.pause", "workflow.schedules.resume", "workflow.schedules.delete", "workflow.schedules.occurrences.list", ] assert calls[0]["params"] == { "schedule_id": "s", "deployment_id": "dep.personal", "trigger": _cron(), "input_bindings": [], "overlap": "skip", "misfire": "skip", "max_active_runs": 1, "lateness_allowance_s": 60.0, "max_steps": None, "enabled": True, } assert calls[3]["params"] == { "schedule_id": "s", "expected_revision": 1, "deployment_id": None, "trigger": None, "input_bindings": None, "overlap": None, "misfire": None, "max_active_runs": 3, "lateness_allowance_s": None, "max_steps": None, "enabled": None, } assert calls[7]["params"] == { "schedule_id": "s", "cursor": None, "limit": 2, } def test_rpc_client_satisfies_schedule_surface_static_shape() -> None: _: type[WorkflowScheduleSurface] = RpcWorkflowApiClient