538 lines
19 KiB
Python
538 lines
19 KiB
Python
"""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,
|
|
ListRunsParams,
|
|
UpdateScheduleParams,
|
|
)
|
|
|
|
|
|
def _cron() -> dict[str, Any]:
|
|
return {"kind": "cron", "expression": "* * * * *", "timezone": "UTC"}
|
|
|
|
|
|
def test_rpc_list_runs_accepts_admitted_status() -> None:
|
|
"""RPC validation exposes the API's admitted-run filter."""
|
|
assert ListRunsParams(status="admitted").status == "admitted"
|
|
|
|
|
|
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 consumes one slot from the stored page.
|
|
assert len(first["occurrences"]) == 2
|
|
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_occurrences_tied_entries_traverse_once(tmp_path) -> None:
|
|
"""Tied admission/stopped entries each arrive exactly once over RPC (B4)."""
|
|
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)
|
|
recorder.record(
|
|
HistoryEntry(
|
|
schedule_id="s",
|
|
kind="admitted",
|
|
resolved_at=base,
|
|
run_id="run-1",
|
|
revision=1,
|
|
reason="rev=1",
|
|
created_at=base,
|
|
)
|
|
)
|
|
recorder.record(
|
|
HistoryEntry(
|
|
schedule_id="s",
|
|
kind="interrupted",
|
|
resolved_at=base,
|
|
run_id="run-1",
|
|
revision=1,
|
|
reason="fresh-result",
|
|
checkpoint_id="run-1.000001",
|
|
created_at=base + timedelta(minutes=5),
|
|
)
|
|
)
|
|
|
|
seen: list[tuple[str, object]] = []
|
|
cursor: object = None
|
|
while True:
|
|
page = await client.list_schedule_occurrences(
|
|
schedule_id="s",
|
|
cursor=cursor,
|
|
limit=1, # type: ignore[arg-type]
|
|
)
|
|
assert page["total"] == 2
|
|
for row in page["occurrences"]:
|
|
seen.append((row["kind"], row["checkpoint_id"]))
|
|
cursor = page["next_cursor"]
|
|
if cursor is None:
|
|
break
|
|
assert seen == [("admitted", None), ("interrupted", "run-1.000001")]
|
|
|
|
|
|
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
|