sched: docs, probe retirement, MCP guard, coverage pins, executable example (T14)
This commit is contained in:
@@ -117,3 +117,12 @@ def test_file_run_store_rejects_unsafe_run_id(tmp_path) -> None:
|
||||
def test_workflow_run_record_validates_latest_checkpoint_id() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
run_record("run_123", "../outside")
|
||||
|
||||
|
||||
def test_allocate_run_id_survives_fresh_instances_without_reuse(tmp_path) -> None:
|
||||
first = FileRunStore(tmp_path)
|
||||
assert first.allocate_run_id() == "run-000001"
|
||||
assert first.allocate_run_id() == "run-000002"
|
||||
# A fresh instance (restart boundary) must not reuse identities.
|
||||
second = FileRunStore(tmp_path)
|
||||
assert second.allocate_run_id() == "run-000003"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Executable scheduling example: one deployment run on a timer.
|
||||
|
||||
This is the runnable companion to
|
||||
``docs/deployment_scheduling.md`` ("hypothetically used as follows"):
|
||||
a local server plus the opt-in scheduler admit and complete one
|
||||
one-shot scheduled run of a constant workflow, then show its occurrence
|
||||
history. Run it with::
|
||||
|
||||
uv run pytest -q tests/examples/test_scheduled_deployment_example.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from wf_api.models import RawWorkflowPlan
|
||||
from wf_core import END
|
||||
from wf_server import build_local_static_workflow_server
|
||||
from wf_server.scheduling import build_scheduler_service
|
||||
from wf_scheduling.lifecycle import SchedulerServiceConfig
|
||||
from wf_scheduling.store import FileScheduleStore
|
||||
|
||||
|
||||
def _constant_plan() -> RawWorkflowPlan:
|
||||
return RawWorkflowPlan.model_validate(
|
||||
{
|
||||
"name": "scheduled_hello",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"state_schema": {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
},
|
||||
"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 on a 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 _wait_for(condition: Any, timeout: float = 30.0) -> None:
|
||||
async with asyncio.timeout(timeout):
|
||||
while not condition():
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
async def test_scheduled_deployment_completes_on_a_timer(tmp_path: Any) -> None:
|
||||
root = tmp_path / "store"
|
||||
server = build_local_static_workflow_server(root, schedules=True)
|
||||
await server.api.create_artifact_from_plan(
|
||||
artifact_id="scheduled_hello",
|
||||
version=1,
|
||||
title="Scheduled Hello",
|
||||
plan=_constant_plan(),
|
||||
outcomes=["ok"],
|
||||
source_bindings={},
|
||||
)
|
||||
await server.api.save_deployment(
|
||||
{
|
||||
"id": "scheduled_hello.default",
|
||||
"artifact_id": "scheduled_hello",
|
||||
"artifact_version": 1,
|
||||
"bindings": {},
|
||||
}
|
||||
)
|
||||
due = datetime.now(UTC) + timedelta(seconds=0.5)
|
||||
created = await server.api.schedules.create_schedule(
|
||||
schedule_id="hello-once",
|
||||
deployment_id="scheduled_hello.default",
|
||||
trigger={"kind": "oneshot", "at": due.isoformat()},
|
||||
)
|
||||
assert created["revision"] == 1
|
||||
|
||||
service = build_scheduler_service(
|
||||
server,
|
||||
SchedulerServiceConfig(poll_interval_s=0.05, capacity=2),
|
||||
)
|
||||
try:
|
||||
await service.start()
|
||||
|
||||
def _completed() -> bool:
|
||||
runs = list(server.stores.run_store.list_runs())
|
||||
return any(run.status.value == "completed" for run in runs)
|
||||
|
||||
await _wait_for(_completed)
|
||||
run_id = next(
|
||||
run.id
|
||||
for run in server.stores.run_store.list_runs()
|
||||
if run.status.value == "completed"
|
||||
)
|
||||
inspected = await server.api.inspect_run(run_id=run_id)
|
||||
assert inspected["status"] == "completed"
|
||||
assert (inspected["output"] or {})["result"] == "hello on a schedule"
|
||||
|
||||
page = await server.api.schedules.list_schedule_occurrences(
|
||||
schedule_id="hello-once"
|
||||
)
|
||||
kinds = [row["kind"] for row in page["occurrences"]]
|
||||
assert "admitted" in kinds
|
||||
assert "completed" in kinds
|
||||
finally:
|
||||
await service.stop()
|
||||
|
||||
# The schedule definition survives its run; history is retained.
|
||||
assert FileScheduleStore(root).get_schedule("hello-once").exhausted is True
|
||||
@@ -353,3 +353,34 @@ def test_poll_one_uses_fresh_definition_after_edit(tmp_path: Path) -> None:
|
||||
assert admission.resolved_input["team"] == "new"
|
||||
assert admission.schedule_revision == 2
|
||||
sched.ownership.release()
|
||||
|
||||
|
||||
def test_manual_and_other_schedule_runs_are_overlap_independent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from wf_api.run_lifecycle import (
|
||||
materialize_admitted_view,
|
||||
persist_admission,
|
||||
)
|
||||
|
||||
sched, store, runs, sources = _harness(tmp_path, script={"*": "hang"})
|
||||
t0 = ts(2026, 9, 8, 12, 0)
|
||||
# An active run of another schedule occupies only its own slot.
|
||||
_add(sched, store, sources, "b", OneShotSource(t0), t0 - timedelta(hours=1))
|
||||
assert sched.poll(t0)["b"].startswith("admit:run-")
|
||||
# A manual run (no schedule owner) participates in no overlap check.
|
||||
manual_id = runs.allocate_run_id()
|
||||
manual = persist_admission(
|
||||
store=runs,
|
||||
run_id=manual_id,
|
||||
environment=fixture_environment(object()),
|
||||
resolved_input={},
|
||||
max_steps=None,
|
||||
)
|
||||
materialize_admitted_view(store=runs, admission=manual)
|
||||
# A due one-shot admits despite both unrelated active runs.
|
||||
_add(sched, store, sources, "a", OneShotSource(t0), t0 - timedelta(hours=1))
|
||||
result = sched.poll(t0 + timedelta(seconds=1))
|
||||
assert result["a"].startswith("admit:run-")
|
||||
assert result["b"] == "exhausted"
|
||||
sched.ownership.release()
|
||||
|
||||
@@ -565,3 +565,81 @@ def test_rpc_server_cli_flag_overrides_disabled_config_scheduler(
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["lifespan"] is not None
|
||||
|
||||
|
||||
def test_rpc_server_cli_enable_scheduler_rejects_mcp_backed_server(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"store_root": str(tmp_path / "store"), "connections": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_build_mcp_server(path):
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"wf_server.cli.build_workflow_server_from_legacy_mcp_config",
|
||||
fake_build_mcp_server,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"--mcp-config",
|
||||
str(config_path),
|
||||
"--enable-scheduler",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "requires a local/static server" in result.output
|
||||
|
||||
|
||||
def test_rpc_server_cli_config_mcp_sources_reject_scheduler(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"store": {"kind": "filesystem", "root": ".wf_store"},
|
||||
"sources": [
|
||||
{
|
||||
"kind": "mcp",
|
||||
"id": "everything.default",
|
||||
"provider": "everything",
|
||||
"account": "default",
|
||||
"transport": {
|
||||
"kind": "stdio",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-everything"],
|
||||
},
|
||||
}
|
||||
],
|
||||
"scheduler": {"enabled": True},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_build_server(config, *, drafts=False):
|
||||
return object()
|
||||
|
||||
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"wf_server.cli.build_workflow_server_from_workflow_config",
|
||||
fake_build_server,
|
||||
)
|
||||
monkeypatch.setattr("wf_server.cli.create_rpc_app", fake_create_rpc_app)
|
||||
|
||||
result = CliRunner().invoke(app, ["--config", str(config_path)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "requires a local/static server" in result.output
|
||||
|
||||
Reference in New Issue
Block a user