sched: schedule RPC methods, transport/client decoding, round trips (T13)

This commit is contained in:
lda
2026-09-09 11:49:35 +07:00 Verified
parent 7f00422fd7
commit 2c84aba96b
16 changed files with 1717 additions and 0 deletions
+24
View File
@@ -70,6 +70,30 @@ class FakeWorkflowClient:
async def read_run_trace(self, **params: Any) -> object:
return self._response("workflow.runs.trace", params)
async def create_schedule(self, **params: Any) -> object:
return self._response("workflow.schedules.create", params)
async def get_schedule(self, **params: Any) -> object:
return self._response("workflow.schedules.get", params)
async def list_schedules(self, **params: Any) -> object:
return self._response("workflow.schedules.list", params)
async def update_schedule(self, **params: Any) -> object:
return self._response("workflow.schedules.update", params)
async def pause_schedule(self, **params: Any) -> object:
return self._response("workflow.schedules.pause", params)
async def resume_schedule(self, **params: Any) -> object:
return self._response("workflow.schedules.resume", params)
async def delete_schedule(self, **params: Any) -> object:
return self._response("workflow.schedules.delete", params)
async def list_schedule_occurrences(self, **params: Any) -> object:
return self._response("workflow.schedules.occurrences.list", params)
async def _call(self, method: str, params: dict[str, Any]) -> object:
return self._response(method, params)
+262
View File
@@ -0,0 +1,262 @@
"""T13 schedule client tests (codec + snapshot + facade accessors)."""
from __future__ import annotations
from typing import Any, cast
import pytest
from wf_client import App, Schedule
from wf_client.codec import (
decode_occurrence_page,
decode_schedule_list,
decode_schedule_result,
)
from wf_client.errors import InvalidResponse
from wf_client.protocols import WorkflowClientPort
from .conftest import FakeWorkflowClient
def _schedule_payload(**overrides: Any) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": "s",
"deployment_id": "dep.personal",
"trigger": {"kind": "cron", "expression": "* * * * *", "timezone": "UTC"},
"input_bindings": [{"target": "msg", "value": "hi"}],
"max_steps": None,
"overlap": "skip",
"misfire": "skip",
"max_active_runs": 1,
"lateness_allowance_s": 60.0,
"revision": 1,
"enabled": True,
"paused": False,
"deleted": False,
"exhausted": False,
"blocked_reason": None,
"created_at": "2026-09-08T12:00:00+00:00",
"updated_at": "2026-09-08T12:00:00+00:00",
}
payload.update(overrides)
return payload
def _occurrence_payload(**overrides: Any) -> dict[str, Any]:
payload: dict[str, Any] = {
"occurrences": [
{
"schedule_id": "s",
"occurrence_id": "s|2026-09-08T12:00:00+00:00",
"kind": "admitted",
"resolved_at": "2026-09-08T12:00:00+00:00",
"run_id": "run-0",
"revision": 1,
"reason": "rev=1",
"admitted_at": "2026-09-08T12:00:00+00:00",
"started_at": None,
"checkpoint_id": None,
"interval_start": None,
"interval_end": None,
"interval_count": 0,
"created_at": "2026-09-08T12:00:00+00:00",
}
],
"total": 1,
"cursor": None,
"next_cursor": None,
"limit": 50,
}
payload.update(overrides)
return payload
class _Port:
def __init__(self, payload: dict[str, Any] | None = None) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
self.payload = payload or _schedule_payload(revision=2)
async def get_schedule(self, **params: Any) -> object:
self.calls.append(("get_schedule", params))
return self.payload
def test_decode_schedule_result_returns_wire_projection() -> None:
result = decode_schedule_result(_schedule_payload())
assert result["id"] == "s"
assert result["deployment_id"] == "dep.personal"
assert result["revision"] == 1
assert result["enabled"] is True
def test_decode_schedule_result_rejects_malformed_payload() -> None:
with pytest.raises(InvalidResponse, match="workflow.schedules.get"):
decode_schedule_result({"id": "s"})
def test_decode_schedule_list_and_occurrence_page() -> None:
listed = decode_schedule_list({"schedules": [_schedule_payload()]})
page = decode_occurrence_page(_occurrence_payload())
assert listed["schedules"][0]["id"] == "s"
assert page["total"] == 1
assert page["occurrences"][0]["run_id"] == "run-0"
with pytest.raises(InvalidResponse, match="workflow.schedules.list"):
decode_schedule_list({"schedules": [{"id": "broken"}]})
with pytest.raises(InvalidResponse, match="workflow.schedules.occurrences.list"):
decode_occurrence_page({"total": "many"})
@pytest.mark.asyncio
async def test_schedule_snapshot_exposes_minimal_surface() -> None:
schedule = Schedule.from_payload(
cast(WorkflowClientPort, _Port()), _schedule_payload()
)
assert schedule.schedule_id == "s"
assert schedule.id == "s"
assert schedule.deployment_id == "dep.personal"
assert schedule.trigger == {
"kind": "cron",
"expression": "* * * * *",
"timezone": "UTC",
}
assert schedule.input_bindings == [{"target": "msg", "value": "hi"}]
assert schedule.revision == 1
assert schedule.enabled is True
assert schedule.paused is False
assert schedule.deleted is False
assert schedule.overlap == "skip"
assert schedule.misfire == "skip"
assert schedule.max_active_runs == 1
assert schedule.lateness_allowance_s == 60.0
assert schedule.max_steps is None
@pytest.mark.asyncio
async def test_schedule_snapshot_defensively_copies_json_data() -> None:
schedule = Schedule.from_payload(
cast(WorkflowClientPort, _Port()), _schedule_payload()
)
exposed_trigger = schedule.trigger
exposed_bindings = schedule.input_bindings
assert isinstance(exposed_trigger, dict)
exposed_trigger["expression"] = "mutated"
exposed_bindings[0]["value"] = "mutated"
exposed_bindings.append({"target": "extra", "value": 1})
assert schedule.trigger == {
"kind": "cron",
"expression": "* * * * *",
"timezone": "UTC",
}
assert schedule.input_bindings == [{"target": "msg", "value": "hi"}]
@pytest.mark.asyncio
async def test_schedule_rejects_mismatched_identity() -> None:
with pytest.raises(InvalidResponse, match="workflow.schedules.get"):
Schedule.from_payload(
cast(WorkflowClientPort, _Port()),
_schedule_payload(id="other"),
expected_schedule_id="s",
)
@pytest.mark.asyncio
async def test_refresh_returns_a_new_snapshot() -> None:
port = _Port()
original = Schedule.from_payload(
cast(WorkflowClientPort, port), _schedule_payload()
)
refreshed = await original.refresh()
assert refreshed is not original
assert refreshed.revision == 2
assert original.revision == 1
assert port.calls == [("get_schedule", {"schedule_id": "s"})]
@pytest.mark.asyncio
async def test_app_schedule_accessors_round_trip() -> None:
payload = _schedule_payload()
fake = FakeWorkflowClient(
**{
"workflow.schedules.create": payload,
"workflow.schedules.get": payload,
"workflow.schedules.list": {"schedules": [payload]},
"workflow.schedules.update": _schedule_payload(revision=2),
"workflow.schedules.pause": _schedule_payload(paused=True),
"workflow.schedules.resume": _schedule_payload(paused=False),
"workflow.schedules.delete": _schedule_payload(deleted=True),
"workflow.schedules.occurrences.list": _occurrence_payload(),
}
)
app = App._from_port(cast(WorkflowClientPort, fake))
created = await app.create_schedule(
schedule_id="s",
deployment_id="dep.personal",
trigger={
"kind": "cron",
"expression": "* * * * *",
"timezone": "UTC",
},
)
fetched = await app.schedule("s")
listed = await app.schedules()
updated = await app.update_schedule(
schedule_id="s", expected_revision=1, max_active_runs=3
)
paused = await app.pause_schedule("s")
resumed = await app.resume_schedule("s")
deleted = await app.delete_schedule("s")
page = await app.schedule_occurrences("s", limit=10)
assert created.schedule_id == "s"
assert created.revision == 1
assert fetched.revision == 1
assert [item.schedule_id for item in listed] == ["s"]
assert updated.revision == 2
assert paused.paused is True
assert resumed.paused is False
assert deleted.deleted is True
assert page["total"] == 1
assert page["occurrences"][0]["run_id"] == "run-0"
assert fake.calls[0] == (
"workflow.schedules.create",
{
"schedule_id": "s",
"deployment_id": "dep.personal",
"trigger": {
"kind": "cron",
"expression": "* * * * *",
"timezone": "UTC",
},
"input_bindings": None,
"overlap": "skip",
"misfire": "skip",
"max_active_runs": 1,
"lateness_allowance_s": 60.0,
"max_steps": None,
"enabled": True,
},
)
assert (
"workflow.schedules.occurrences.list",
{"schedule_id": "s", "cursor": None, "limit": 10},
) in fake.calls
@pytest.mark.asyncio
async def test_app_schedule_rejects_mismatched_identity() -> None:
fake = FakeWorkflowClient(
**{"workflow.schedules.get": _schedule_payload(id="other")}
)
app = App._from_port(cast(WorkflowClientPort, fake))
with pytest.raises(InvalidResponse, match="workflow.schedules.get"):
await app.schedule("s")
@@ -860,3 +860,68 @@ def test_openrpc_exposes_typed_run_results(
component_name=component_name,
properties=properties,
)
@pytest.mark.parametrize(
("method_name", "component_name", "properties"),
[
(
"workflow.schedules.create",
"ScheduleResult",
{
"id",
"deployment_id",
"trigger",
"revision",
"enabled",
},
),
(
"workflow.schedules.get",
"ScheduleResult",
{"id", "revision", "paused", "deleted"},
),
(
"workflow.schedules.list",
"ListSchedulesResult",
{"schedules"},
),
(
"workflow.schedules.update",
"ScheduleResult",
{"id", "revision", "updated_at"},
),
(
"workflow.schedules.pause",
"ScheduleResult",
{"id", "paused"},
),
(
"workflow.schedules.resume",
"ScheduleResult",
{"id", "paused"},
),
(
"workflow.schedules.delete",
"ScheduleResult",
{"id", "deleted"},
),
(
"workflow.schedules.occurrences.list",
"OccurrencePage",
{"occurrences", "total", "cursor", "next_cursor", "limit"},
),
],
)
def test_openrpc_exposes_typed_schedule_results(
openrpc_document: dict[str, Any],
method_name: str,
component_name: str,
properties: set[str],
) -> None:
_assert_result_component(
openrpc_document,
method_name=method_name,
component_name=component_name,
properties=properties,
)
@@ -0,0 +1,477 @@
"""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