sched: schedule administration API, admitted inspection, occurrence pages (T13)

This commit is contained in:
lda
2026-09-09 11:33:12 +07:00 Verified
parent 414d6f1922
commit 7f00422fd7
11 changed files with 1572 additions and 10 deletions
+14 -1
View File
@@ -23,7 +23,13 @@ from .drafts import WorkflowDraftApi
from .durable_context import durable_workflow_api, require_workflow_stores from .durable_context import durable_workflow_api, require_workflow_stores
from .listing import matches_query, paged_list_payload from .listing import matches_query, paged_list_payload
from .local_sources import builtin_sources, get_qualified_spec, qualify_spec from .local_sources import builtin_sources, get_qualified_spec, qualify_spec
from .models import RawWorkflowPlan, TraceRange from .models import (
ListSchedulesResult,
OccurrencePage,
RawWorkflowPlan,
ScheduleResult,
TraceRange,
)
from .next_actions import NextActionPatchExample, NextActions, NextActionTool from .next_actions import NextActionPatchExample, NextActions, NextActionTool
from .operation_context import ( from .operation_context import (
WorkflowEventRecorder, WorkflowEventRecorder,
@@ -35,6 +41,7 @@ from .operation_context import (
from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id
from .runs import WorkflowRunApi from .runs import WorkflowRunApi
from .runtime_dependencies import RuntimeDependencies, resolve_runtime_dependencies from .runtime_dependencies import RuntimeDependencies, resolve_runtime_dependencies
from .schedules import WorkflowScheduleApi
from .service import WorkflowApi from .service import WorkflowApi
from .source_admin import WorkflowSourceAdminApi from .source_admin import WorkflowSourceAdminApi
from .source_refs import SourceResourceRef from .source_refs import SourceResourceRef
@@ -53,6 +60,7 @@ from .surface import (
WorkflowDeploymentSurface, WorkflowDeploymentSurface,
WorkflowDraftSurface, WorkflowDraftSurface,
WorkflowRunSurface, WorkflowRunSurface,
WorkflowScheduleSurface,
WorkflowSourceAdminSurface, WorkflowSourceAdminSurface,
WorkflowSourceRegistrySurface, WorkflowSourceRegistrySurface,
) )
@@ -78,6 +86,7 @@ __all__ = [
"AuthRecord", "AuthRecord",
"AuthStore", "AuthStore",
"CapabilityStepUpdate", "CapabilityStepUpdate",
"ListSchedulesResult",
"MissingDecision", "MissingDecision",
"MissingDecisionKind", "MissingDecisionKind",
"NextActionPatchExample", "NextActionPatchExample",
@@ -85,9 +94,11 @@ __all__ = [
"NextActions", "NextActions",
"OutcomeCandidate", "OutcomeCandidate",
"OutcomeCandidateKind", "OutcomeCandidateKind",
"OccurrencePage",
"RawWorkflowPlan", "RawWorkflowPlan",
"RouteSource", "RouteSource",
"RuntimeDependencies", "RuntimeDependencies",
"ScheduleResult",
"SourceResourceRef", "SourceResourceRef",
"TraceRange", "TraceRange",
"WorkflowAdminApi", "WorkflowAdminApi",
@@ -111,6 +122,8 @@ __all__ = [
"WorkflowRunApi", "WorkflowRunApi",
"WorkflowRunSurface", "WorkflowRunSurface",
"WorkflowRuntimeRunner", "WorkflowRuntimeRunner",
"WorkflowScheduleApi",
"WorkflowScheduleSurface",
"WorkflowSourceAdminApi", "WorkflowSourceAdminApi",
"WorkflowSourceAdminSurface", "WorkflowSourceAdminSurface",
"WorkflowSourceRegistryApi", "WorkflowSourceRegistryApi",
+9 -2
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from .operation_context import WorkflowOperationContext from .operation_context import WorkflowOperationContext
from .service import WorkflowApi from .service import WorkflowApi
from .stores import WorkflowStores from .stores import WorkflowStores
@@ -38,10 +40,15 @@ def durable_workflow_api(
context: WorkflowOperationContext, context: WorkflowOperationContext,
*, *,
drafts: bool = False, drafts: bool = False,
schedule_store: Any | None = None,
) -> WorkflowApi: ) -> WorkflowApi:
"""Construct a durable API, optionally omitting the draft product surface.""" """Construct a durable API, optionally omitting the draft product surface.
An explicit schedule store enables the T13 administration surface;
without one the schedule sub-API raises KeyError, like runs do.
"""
require_workflow_stores(context, drafts=drafts) require_workflow_stores(context, drafts=drafts)
return WorkflowApi(context, drafts=drafts) return WorkflowApi(context, drafts=drafts, schedule_store=schedule_store)
__all__ = ["durable_workflow_api", "require_workflow_stores"] __all__ = ["durable_workflow_api", "require_workflow_stores"]
+4
View File
@@ -101,6 +101,7 @@ from .runs import (
TraceEntryPayload, TraceEntryPayload,
WorkflowRefPayload, WorkflowRefPayload,
) )
from .schedules import ListSchedulesResult, OccurrencePage, ScheduleResult
from .source_registry import ( from .source_registry import (
ApplyRegistryChangesResult, ApplyRegistryChangesResult,
InspectRegistryEntryResult, InspectRegistryEntryResult,
@@ -186,11 +187,13 @@ __all__ = [
"ListRunsResult", "ListRunsResult",
"ListRegistryEntriesResult", "ListRegistryEntriesResult",
"ListSourcesResult", "ListSourcesResult",
"ListSchedulesResult",
"NextActionPatchExamplePayload", "NextActionPatchExamplePayload",
"NextActionsPayload", "NextActionsPayload",
"NodeSpecCapabilityDetail", "NodeSpecCapabilityDetail",
"NodeSpecCapabilitySummary", "NodeSpecCapabilitySummary",
"NodeSpecInventoryPayload", "NodeSpecInventoryPayload",
"OccurrencePage",
"PageMetadataPayload", "PageMetadataPayload",
"PatchDraftResult", "PatchDraftResult",
"PatchedDraftInvalidResult", "PatchedDraftInvalidResult",
@@ -208,6 +211,7 @@ __all__ = [
"RequiredCapabilityPayload", "RequiredCapabilityPayload",
"RemoveRegistryEntryResult", "RemoveRegistryEntryResult",
"SaveArtifactResult", "SaveArtifactResult",
"ScheduleResult",
"ValidateArtifactPlanResult", "ValidateArtifactPlanResult",
"SavedDraftArtifactResult", "SavedDraftArtifactResult",
"SaveDeploymentResult", "SaveDeploymentResult",
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from typing import Any, TypedDict
class ScheduleResult(TypedDict):
"""JSON projection of one schedule definition.
Mirrors ``Schedule.model_dump(mode="json")`` exactly; ``created_at`` and
``updated_at`` are ISO strings in this projection.
"""
id: str
deployment_id: str
trigger: dict[str, Any]
input_bindings: list[dict[str, Any]]
max_steps: int | None
overlap: str
misfire: str
max_active_runs: int
lateness_allowance_s: float
revision: int
enabled: bool
paused: bool
deleted: bool
exhausted: bool
blocked_reason: str | None
created_at: str
updated_at: str
class ListSchedulesResult(TypedDict):
"""Schedule listing payload shared by the admin surface."""
schedules: list[ScheduleResult]
class OccurrencePage(TypedDict):
"""One occurrence-history page, optionally with a live pending row.
Matches the schedule-store page shape (``occurrences``, ``total``,
``cursor``, ``next_cursor``, ``limit``); see
``WorkflowScheduleApi.list_schedule_occurrences`` for the pending-row
synthesis contract.
"""
occurrences: list[dict[str, Any]]
total: int
cursor: str | None
next_cursor: str | None
limit: int
+4
View File
@@ -110,6 +110,10 @@ class WorkflowOperationContext:
specs: WorkflowSpecProvider specs: WorkflowSpecProvider
runtime: WorkflowRuntimeRunner runtime: WorkflowRuntimeRunner
live_sources: WorkflowLiveSourceChecker | None = None live_sources: WorkflowLiveSourceChecker | None = None
# Optional schedule store for the T13 administration surface. None keeps
# every existing construction site working; the schedule API raises
# KeyError when it is absent, exactly like runs with a missing store.
schedule_store: Any | None = None
__all__ = [ __all__ = [
+26 -5
View File
@@ -337,13 +337,34 @@ class WorkflowRunApi:
} }
async def inspect_run(self, *, run_id: str) -> RunResult: async def inspect_run(self, *, run_id: str) -> RunResult:
"""Return one durable stopped-run summary without debug trace entries. """Return one durable run summary without debug trace entries.
Admitted runs with no stopped checkpoint fail closed here (no Admitted runs with no stopped checkpoint report their durable
fabricated trace/output); checkpoint-free inspection arrives with admission truthfully (status admitted, no checkpoint id, no
the scheduling administration surface (T13). trace slice, output, or interrupt) instead of failing closed;
inspection never fabricates checkpoint-derived state.
``read_run_trace`` still rejects checkpoint-less runs.
""" """
record, run = load_stored_run(self._run_store(), run_id) store = self._run_store()
record = store.get_run(run_id)
if record.status is StoredRunStatus.ADMITTED:
environment = record.environment
try:
max_steps = store.get_admission(run_id).max_steps
except KeyError:
max_steps = None
return _run_payload(
deployment=environment.deployment,
artifact=environment.root_artifact,
status=record.status.value,
run_id=record.id,
resume_readiness=record.resume_readiness.value,
diagnostics=record.diagnostics,
trace_count=0,
max_steps=max_steps,
steps_executed=0,
)
record, run = load_stored_run(store, run_id)
environment = record.environment environment = record.environment
return _run_payload( return _run_payload(
deployment=environment.deployment, deployment=environment.deployment,
+421
View File
@@ -0,0 +1,421 @@
"""Schedule administration operations (T13).
Same-process admin surface over the file schedule store: create, inspect,
list, revision-checked update, pause, resume, delete, and paginated
occurrence inspection with a live pending-candidate synthesis.
There is deliberately no ownership requirement on these admin ops: this is
the same-process administration surface, the service observes fresh reads
from the store on every call, and second-process schedulers remain
unsupported per the store boundary (see ``wf_scheduling.ownership``).
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from wf_core import RunLimits
from wf_scheduling.history import FileScheduleHistoryRecorder, HistoryEntry
from wf_scheduling.models import OccurrenceRecord, Schedule
from wf_scheduling.occurrences import occurrence_id
from wf_scheduling.prepare import PreparationRejected, SchedulePreparer
from .models import (
JsonProjector,
ListSchedulesResult,
OccurrencePage,
ScheduleResult,
)
from .operation_context import WorkflowOperationContext
from .run_lifecycle import create_pinned_environment
from .saved_subgraphs import resolve_saved_subgraph_tree
_PROJECT_SCHEDULE = JsonProjector(ScheduleResult)
_PROJECT_LIST_SCHEDULES = JsonProjector(ListSchedulesResult)
_PROJECT_OCCURRENCE_PAGE = JsonProjector(OccurrencePage)
class _ContextDeploymentDirectory:
"""Deployment contract source over the API artifact store.
Mirrors ``StoreDeploymentDirectory`` (``wf_scheduling.lifecycle``)
without importing the server-lifecycle layer: the deployment revision
plus the required keys of the pinned root artifact's input schema.
Unknown ids raise ``KeyError``.
"""
def __init__(self, artifact_store: Any) -> None:
self._artifact_store = artifact_store
def deployment_revision(self, deployment_id: str) -> int:
return int(self._artifact_store.get_deployment(deployment_id).revision)
def required_inputs(self, deployment_id: str) -> list[str]:
deployment = self._artifact_store.get_deployment(deployment_id)
artifact = self._artifact_store.get_artifact(
deployment.artifact_id, deployment.artifact_version
)
schema = getattr(artifact, "input_schema", None)
if not isinstance(schema, dict):
return []
required = schema.get("required", [])
if not isinstance(required, list):
return []
return [key for key in required if isinstance(key, str)]
def _build_pinned_environment(artifact_store: Any) -> Callable[[Any], Any]:
"""Pin deployment + root artifact + subgraph tree for sample validation."""
def build(sched: Any) -> Any:
deployment = artifact_store.get_deployment(sched.deployment_id)
artifact = artifact_store.get_artifact(
deployment.artifact_id, deployment.artifact_version
)
tree = resolve_saved_subgraph_tree(
root_artifact=artifact, artifact_store=artifact_store
)
return create_pinned_environment(
deployment=deployment, artifact=artifact, tree=tree
)
return build
class WorkflowScheduleApi:
"""Schedule administration over the configured schedule store.
The store resolves from the operation context (or from an explicitly
passed store, which wins); every method raises ``KeyError`` when no
schedule store is configured, exactly like runs with a missing store.
"""
def __init__(
self,
context: WorkflowOperationContext,
*,
schedule_store: Any | None = None,
) -> None:
self.context = context
self._explicit_schedule_store = schedule_store
def _schedule_store(self) -> Any:
store = self._explicit_schedule_store
if store is None:
store = getattr(self.context, "schedule_store", None)
if store is None:
raise KeyError("workflow schedule store is not configured")
return store
def _artifact_store(self) -> Any:
if self.context.artifact_store is None:
raise KeyError("workflow artifact store is not configured")
return self.context.artifact_store
@staticmethod
def _check_trigger(schedule: Schedule) -> None:
"""Reject triggers the poll loop could not build a source for."""
from wf_scheduling.poll import source_for_trigger
try:
source_for_trigger(schedule.trigger)
except Exception as exc:
raise ValueError(f"invalid schedule trigger: {exc}") from exc
@staticmethod
def _check_max_steps(max_steps: int | None) -> None:
"""Reuse the manual-run step-budget validator for schedules."""
if max_steps is None:
return
try:
RunLimits(max_steps=max_steps)
except (TypeError, ValueError) as exc:
raise ValueError(f"invalid max_steps {max_steps!r}: {exc}") from exc
def _validate_sample(self, schedule: Schedule) -> None:
"""Resolve the bindings against a synthetic occurrence and validate.
A real ``SchedulePreparer`` wired to the context artifact store
resolves the schedule's input bindings for the current instant and
validates the result against the pinned root artifact's input
schema, so a schedule cannot be saved when its very first
occurrence would fail preflight. Failures raise ``ValueError``.
"""
artifact_store = self._artifact_store()
preparer = SchedulePreparer(
_ContextDeploymentDirectory(artifact_store),
_build_pinned_environment(artifact_store),
)
now = datetime.now(UTC)
result = preparer.prepare(sched=schedule, intended=now, now=now)
if isinstance(result, PreparationRejected):
raise ValueError(f"schedule sample occurrence rejected: {result.reason}")
def _validate_definition(self, schedule: Schedule) -> None:
"""Run the trigger/budget/sample checks shared by create and update."""
self._check_max_steps(schedule.max_steps)
self._check_trigger(schedule)
self._validate_sample(schedule)
async def create_schedule(
self,
*,
schedule_id: str,
deployment_id: str,
trigger: dict[str, Any],
input_bindings: list[dict[str, Any]] | None = None,
overlap: str = "skip",
misfire: str = "skip",
max_active_runs: int = 1,
lateness_allowance_s: float = 60.0,
max_steps: int | None = None,
enabled: bool = True,
) -> ScheduleResult:
"""Create one schedule after deployment, trigger, and sample checks.
Unknown deployments raise ``KeyError``; bad triggers, binding
shapes, budgets, and sample-occurrence rejections raise
``ValueError``; duplicate ids (deleted ids are never reusable)
raise ``ScheduleExistsError``.
"""
store = self._schedule_store()
now = datetime.now(UTC)
# KeyError first: the deployment must exist before any validation.
self._artifact_store().get_deployment(deployment_id)
schedule = Schedule.model_validate(
{
"id": schedule_id,
"deployment_id": deployment_id,
"trigger": trigger,
"input_bindings": input_bindings if input_bindings is not None else [],
"max_steps": max_steps,
"overlap": overlap,
"misfire": misfire,
"max_active_runs": max_active_runs,
"lateness_allowance_s": lateness_allowance_s,
"revision": 1,
"enabled": enabled,
"paused": False,
"deleted": False,
"exhausted": False,
"blocked_reason": None,
"created_at": now,
"updated_at": now,
}
)
self._validate_definition(schedule)
stored = store.create_schedule(schedule)
return _PROJECT_SCHEDULE(stored.model_dump(mode="json"))
async def get_schedule(self, *, schedule_id: str) -> ScheduleResult:
"""Return one schedule payload; unknown ids raise ``KeyError``."""
schedule = self._schedule_store().get_schedule(schedule_id)
return _PROJECT_SCHEDULE(schedule.model_dump(mode="json"))
async def list_schedules(
self, *, include_deleted: bool = False
) -> ListSchedulesResult:
"""Return schedule payloads ordered by id (deleted excluded by default)."""
schedules = self._schedule_store().list_schedules(
include_deleted=include_deleted
)
return _PROJECT_LIST_SCHEDULES(
{
"schedules": [
_PROJECT_SCHEDULE(item.model_dump(mode="json"))
for item in schedules
]
}
)
async def update_schedule(
self,
*,
schedule_id: str,
expected_revision: int,
deployment_id: str | None = None,
trigger: dict[str, Any] | None = None,
input_bindings: list[dict[str, Any]] | None = None,
overlap: str | None = None,
misfire: str | None = None,
max_active_runs: int | None = None,
lateness_allowance_s: float | None = None,
max_steps: int | None = None,
enabled: bool | None = None,
) -> ScheduleResult:
"""Apply a partial edit under an optimistic revision check.
Only provided fields change; ``revision`` becomes
``expected_revision + 1`` and ``updated_at`` becomes now, while
``created_at`` and the admitted-run snapshots stay untouched (later
edits only affect future admissions). Stale revisions raise
``StaleScheduleRevisionError``. Side effects mirror
``Scheduler.edit_schedule``: a held candidate is cleared with a
``superseded``/``schedule-edit`` history row, and the consumed
watermark advances to at least now (no backfill).
"""
store = self._schedule_store()
now = datetime.now(UTC)
current = store.get_schedule(schedule_id)
data = current.model_dump(mode="python")
if deployment_id is not None:
data["deployment_id"] = deployment_id
if trigger is not None:
data["trigger"] = trigger
if input_bindings is not None:
data["input_bindings"] = input_bindings
if overlap is not None:
data["overlap"] = overlap
if misfire is not None:
data["misfire"] = misfire
if max_active_runs is not None:
data["max_active_runs"] = max_active_runs
if lateness_allowance_s is not None:
data["lateness_allowance_s"] = lateness_allowance_s
if max_steps is not None:
data["max_steps"] = max_steps
if enabled is not None:
data["enabled"] = enabled
data["revision"] = expected_revision + 1
data["updated_at"] = now
# KeyError first, like create: the (possibly repointed) deployment
# must exist before definition validation.
self._artifact_store().get_deployment(data["deployment_id"])
updated = Schedule.model_validate(data)
self._validate_definition(updated)
stored = store.update_schedule(updated, expected_revision=expected_revision)
old_candidate = store.get_candidate(schedule_id)
if old_candidate is not None:
FileScheduleHistoryRecorder(store).record(
HistoryEntry(
schedule_id=schedule_id,
kind="superseded",
resolved_at=old_candidate.intended_at,
revision=stored.revision,
reason="schedule-edit",
created_at=now,
)
)
store.save_candidate(None, schedule_id=schedule_id)
consumed = store.get_consumed(schedule_id)
store.save_consumed(
schedule_id, max(consumed, now) if consumed is not None else now
)
return _PROJECT_SCHEDULE(stored.model_dump(mode="json"))
async def pause_schedule(self, *, schedule_id: str) -> ScheduleResult:
"""Pause one schedule (mirror the poll-loop paused branch).
Sets ``paused``, clears any held candidate, and advances the
consumed watermark to at least now so the paused span is never
backfilled on resume. No history row is written.
"""
store = self._schedule_store()
now = datetime.now(UTC)
schedule = store.get_schedule(schedule_id)
schedule.paused = True
store.save_schedule(schedule)
store.save_candidate(None, schedule_id=schedule_id)
consumed = store.get_consumed(schedule_id)
store.save_consumed(
schedule_id, max(consumed, now) if consumed is not None else now
)
return _PROJECT_SCHEDULE(schedule.model_dump(mode="json"))
async def resume_schedule(self, *, schedule_id: str) -> ScheduleResult:
"""Resume one schedule (mirror ``Scheduler.resume_schedule``).
Clears ``paused`` (resume selects the next future occurrence),
clears any held candidate, and advances the consumed watermark to
at least now. No history row is written.
"""
store = self._schedule_store()
now = datetime.now(UTC)
schedule = store.get_schedule(schedule_id)
schedule.paused = False
store.save_schedule(schedule)
consumed = store.get_consumed(schedule_id)
store.save_consumed(
schedule_id, max(consumed, now) if consumed is not None else now
)
store.save_candidate(None, schedule_id=schedule_id)
return _PROJECT_SCHEDULE(schedule.model_dump(mode="json"))
async def delete_schedule(self, *, schedule_id: str) -> ScheduleResult:
"""Soft-delete one schedule (mirror the poll-loop deleted branch).
Sets ``deleted`` and clears any held candidate. Runs and history
are untouched, the consumed watermark does not advance, and the id
stays reserved (re-creation is rejected by create).
"""
store = self._schedule_store()
schedule = store.get_schedule(schedule_id)
schedule.deleted = True
store.save_schedule(schedule)
store.save_candidate(None, schedule_id=schedule_id)
return _PROJECT_SCHEDULE(schedule.model_dump(mode="json"))
async def list_schedule_occurrences(
self,
*,
schedule_id: str,
cursor: str | None = None,
limit: int = 50,
) -> OccurrencePage:
"""Return one occurrence-history page with a live pending synthesis.
The stored page keeps the schedule-store shape (``occurrences``,
``total``, ``cursor``, ``next_cursor``, ``limit``). When a
candidate is currently held AND this is the first page (``cursor``
is ``None`` or the legacy first-page offset ``"0"``), one
synthesized ``pending`` row for that candidate
is prepended: ``occurrence_id`` derives from
``(schedule_id, intended instant)``, ``resolved_at`` is the
intended instant, ``revision`` is the candidate revision, and
``reason`` is empty; ``total`` grows by one. Later pages never
carry the row (a repeat fetch after the candidate admits therefore
shows the durable ``admitted`` entry instead of a duplicate
pending projection).
"""
store = self._schedule_store()
# KeyError first: an unknown schedule must not leak pagination.
store.get_schedule(schedule_id)
if limit < 1 or limit > 100:
raise ValueError("limit must be between 1 and 100")
page = store.list_occurrences(schedule_id, cursor=cursor, limit=limit)
candidate = store.get_candidate(schedule_id)
if candidate is not None and cursor in (None, "0"):
now = datetime.now(UTC)
intended = candidate.intended_at
pending = OccurrenceRecord(
schedule_id=schedule_id,
occurrence_id=occurrence_id(schedule_id, intended),
kind="pending",
resolved_at=intended,
run_id=None,
revision=candidate.revision,
reason="",
admitted_at=None,
started_at=None,
checkpoint_id=None,
interval_start=None,
interval_end=None,
interval_count=0,
created_at=now,
)
page = {
"occurrences": [
pending.model_dump(mode="json"),
*page["occurrences"],
],
"total": page["total"] + 1,
"cursor": page["cursor"],
"next_cursor": page["next_cursor"],
"limit": page["limit"],
}
return _PROJECT_OCCURRENCE_PAGE(page)
__all__ = ["WorkflowScheduleApi"]
+115
View File
@@ -39,6 +39,8 @@ from .models import (
ListDeploymentsResult, ListDeploymentsResult,
ListDraftWorkspacesResult, ListDraftWorkspacesResult,
ListRunsResult, ListRunsResult,
ListSchedulesResult,
OccurrencePage,
PatchDraftResult, PatchDraftResult,
RawWorkflowPlan, RawWorkflowPlan,
RunResult, RunResult,
@@ -46,6 +48,7 @@ from .models import (
SaveArtifactResult, SaveArtifactResult,
SavedDraftArtifactResult, SavedDraftArtifactResult,
SaveDeploymentResult, SaveDeploymentResult,
ScheduleResult,
ValidateArtifactPlanResult, ValidateArtifactPlanResult,
ValidateDeploymentResult, ValidateDeploymentResult,
ValidateDraftResult, ValidateDraftResult,
@@ -54,6 +57,7 @@ from .models import (
) )
from .operation_context import WorkflowOperationContext from .operation_context import WorkflowOperationContext
from .runs import TraceRangeLike, WorkflowRunApi from .runs import TraceRangeLike, WorkflowRunApi
from .schedules import WorkflowScheduleApi
def _authoring_schema( def _authoring_schema(
@@ -105,6 +109,7 @@ class WorkflowApi:
context: WorkflowOperationContext, context: WorkflowOperationContext,
*, *,
drafts: bool = False, drafts: bool = False,
schedule_store: Any | None = None,
) -> None: ) -> None:
self.context = context self.context = context
self.capabilities = WorkflowCapabilityApi(context, drafts=drafts) self.capabilities = WorkflowCapabilityApi(context, drafts=drafts)
@@ -120,6 +125,10 @@ class WorkflowApi:
self.artifacts = WorkflowArtifactApi(context, drafts=drafts) self.artifacts = WorkflowArtifactApi(context, drafts=drafts)
self.deployments = WorkflowDeploymentApi(context) self.deployments = WorkflowDeploymentApi(context)
self.runs = WorkflowRunApi(context) self.runs = WorkflowRunApi(context)
# Unlike drafts, the schedule sub-API is always constructed: it
# raises KeyError only when the store is absent, exactly like runs
# with a missing store. An explicit store wins over the context.
self.schedules = WorkflowScheduleApi(context, schedule_store=schedule_store)
def _require_drafts(self) -> WorkflowDraftApi: def _require_drafts(self) -> WorkflowDraftApi:
"""Return the draft service for an explicitly draft-enabled API.""" """Return the draft service for an explicitly draft-enabled API."""
@@ -1089,3 +1098,109 @@ class WorkflowApi:
run_id=run_id, run_id=run_id,
trace_range=trace_range, trace_range=trace_range,
) )
# -- schedules --
async def create_schedule(
self,
*,
schedule_id: str,
deployment_id: str,
trigger: dict[str, Any],
input_bindings: list[dict[str, Any]] | None = None,
overlap: str = "skip",
misfire: str = "skip",
max_active_runs: int = 1,
lateness_allowance_s: float = 60.0,
max_steps: int | None = None,
enabled: bool = True,
) -> ScheduleResult:
return await self.schedules.create_schedule(
schedule_id=schedule_id,
deployment_id=deployment_id,
trigger=trigger,
input_bindings=input_bindings,
overlap=overlap,
misfire=misfire,
max_active_runs=max_active_runs,
lateness_allowance_s=lateness_allowance_s,
max_steps=max_steps,
enabled=enabled,
)
async def get_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult:
return await self.schedules.get_schedule(schedule_id=schedule_id)
async def list_schedules(
self,
*,
include_deleted: bool = False,
) -> ListSchedulesResult:
return await self.schedules.list_schedules(include_deleted=include_deleted)
async def update_schedule(
self,
*,
schedule_id: str,
expected_revision: int,
deployment_id: str | None = None,
trigger: dict[str, Any] | None = None,
input_bindings: list[dict[str, Any]] | None = None,
overlap: str | None = None,
misfire: str | None = None,
max_active_runs: int | None = None,
lateness_allowance_s: float | None = None,
max_steps: int | None = None,
enabled: bool | None = None,
) -> ScheduleResult:
return await self.schedules.update_schedule(
schedule_id=schedule_id,
expected_revision=expected_revision,
deployment_id=deployment_id,
trigger=trigger,
input_bindings=input_bindings,
overlap=overlap,
misfire=misfire,
max_active_runs=max_active_runs,
lateness_allowance_s=lateness_allowance_s,
max_steps=max_steps,
enabled=enabled,
)
async def pause_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult:
return await self.schedules.pause_schedule(schedule_id=schedule_id)
async def resume_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult:
return await self.schedules.resume_schedule(schedule_id=schedule_id)
async def delete_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult:
return await self.schedules.delete_schedule(schedule_id=schedule_id)
async def list_schedule_occurrences(
self,
*,
schedule_id: str,
cursor: str | None = None,
limit: int = 50,
) -> OccurrencePage:
return await self.schedules.list_schedule_occurrences(
schedule_id=schedule_id,
cursor=cursor,
limit=limit,
)
+83
View File
@@ -36,7 +36,9 @@ from .models import (
ListDraftWorkspacesResult, ListDraftWorkspacesResult,
ListRegistryEntriesResult, ListRegistryEntriesResult,
ListRunsResult, ListRunsResult,
ListSchedulesResult,
ListSourcesResult, ListSourcesResult,
OccurrencePage,
PatchDraftResult, PatchDraftResult,
RegistryEntryMutationResult, RegistryEntryMutationResult,
RemoveRegistryEntryResult, RemoveRegistryEntryResult,
@@ -44,6 +46,7 @@ from .models import (
RunTraceResult, RunTraceResult,
SaveArtifactResult, SaveArtifactResult,
SaveDeploymentResult, SaveDeploymentResult,
ScheduleResult,
SourceDiagnosisResult, SourceDiagnosisResult,
ValidateArtifactPlanResult, ValidateArtifactPlanResult,
ValidateDeploymentResult, ValidateDeploymentResult,
@@ -543,12 +546,91 @@ class WorkflowRunSurface(Protocol):
) -> RunTraceResult: ... ) -> RunTraceResult: ...
class WorkflowScheduleSurface(Protocol):
"""Schedule administration methods exposed by workflow frontends.
Same-process admin surface: no ownership requirement (the service
observes fresh store reads; second-process schedulers remain
unsupported per the store boundary).
"""
async def create_schedule(
self,
*,
schedule_id: str,
deployment_id: str,
trigger: dict[str, Any],
input_bindings: list[dict[str, Any]] | None = None,
overlap: str = "skip",
misfire: str = "skip",
max_active_runs: int = 1,
lateness_allowance_s: float = 60.0,
max_steps: int | None = None,
enabled: bool = True,
) -> ScheduleResult: ...
async def get_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult: ...
async def list_schedules(
self,
*,
include_deleted: bool = False,
) -> ListSchedulesResult: ...
async def update_schedule(
self,
*,
schedule_id: str,
expected_revision: int,
deployment_id: str | None = None,
trigger: dict[str, Any] | None = None,
input_bindings: list[dict[str, Any]] | None = None,
overlap: str | None = None,
misfire: str | None = None,
max_active_runs: int | None = None,
lateness_allowance_s: float | None = None,
max_steps: int | None = None,
enabled: bool | None = None,
) -> ScheduleResult: ...
async def pause_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult: ...
async def resume_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult: ...
async def delete_schedule(
self,
*,
schedule_id: str,
) -> ScheduleResult: ...
async def list_schedule_occurrences(
self,
*,
schedule_id: str,
cursor: str | None = None,
limit: int = 50,
) -> OccurrencePage: ...
class WorkflowApiSurface( class WorkflowApiSurface(
WorkflowCapabilitySurface, WorkflowCapabilitySurface,
WorkflowDraftSurface, WorkflowDraftSurface,
WorkflowArtifactSurface, WorkflowArtifactSurface,
WorkflowDeploymentSurface, WorkflowDeploymentSurface,
WorkflowRunSurface, WorkflowRunSurface,
WorkflowScheduleSurface,
Protocol, Protocol,
): ):
"""Public workflow operation surface shared by local and remote adapters.""" """Public workflow operation surface shared by local and remote adapters."""
@@ -660,6 +742,7 @@ __all__ = [
"WorkflowDeploymentSurface", "WorkflowDeploymentSurface",
"WorkflowDraftSurface", "WorkflowDraftSurface",
"WorkflowRunSurface", "WorkflowRunSurface",
"WorkflowScheduleSurface",
"WorkflowSourceAdminSurface", "WorkflowSourceAdminSurface",
"WorkflowSourceRegistrySurface", "WorkflowSourceRegistrySurface",
] ]
+13 -2
View File
@@ -299,8 +299,14 @@ def build_local_static_workflow_server(
*, *,
extra_sources: Mapping[str, CapabilitySource] | None = None, extra_sources: Mapping[str, CapabilitySource] | None = None,
drafts: bool = False, drafts: bool = False,
schedules: bool = False,
) -> WorkflowServer: ) -> WorkflowServer:
"""Build a durable local/static server, with drafts as an explicit opt-in.""" """Build a durable local/static server, with drafts as an explicit opt-in.
``schedules=True`` additionally constructs a file schedule store (which
creates its directory) and enables the T13 schedule administration
surface; the default ``False`` keeps zero behavior change.
"""
config = WorkflowServerConfig(store_root=Path(root)) config = WorkflowServerConfig(store_root=Path(root))
stores = file_workflow_stores(config.store_root, drafts=drafts) stores = file_workflow_stores(config.store_root, drafts=drafts)
events = InMemoryWorkflowEventRecorder() events = InMemoryWorkflowEventRecorder()
@@ -324,7 +330,12 @@ def build_local_static_workflow_server(
runtime=runtime, runtime=runtime,
live_sources=None, live_sources=None,
) )
api = durable_workflow_api(context, drafts=drafts) schedule_store = None
if schedules:
from wf_scheduling.store import FileScheduleStore
schedule_store = FileScheduleStore(config.store_root)
api = durable_workflow_api(context, drafts=drafts, schedule_store=schedule_store)
source_admin = WorkflowSourceAdminApi(context) source_admin = WorkflowSourceAdminApi(context)
admin = WorkflowAdminApi( admin = WorkflowAdminApi(
connections=EmptyWorkflowConnectionProvider(), connections=EmptyWorkflowConnectionProvider(),
+832
View File
@@ -0,0 +1,832 @@
"""T13 schedule administration API tests (wf_api layer only).
Uses real Schedule models plus real FileScheduleStore/FileRunStore/
FileWorkflowArtifactStore in tmp_path, constructing WorkflowScheduleApi
directly over a minimal WorkflowOperationContext (same pattern as
tests/wf_api/test_run_lifecycle.py).
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from wf_api import WorkflowApi
from wf_api.durable_context import durable_workflow_api
from wf_api.models import TraceRange
from wf_api.operation_context import WorkflowOperationContext
from wf_api.run_lifecycle import (
create_pinned_environment,
materialize_admitted_view,
persist_admission,
persist_stopped_run,
)
from wf_api.runs import WorkflowRunApi
from wf_api.saved_subgraphs import SavedSubgraphTree
from wf_api.schedules import WorkflowScheduleApi
from wf_artifacts import (
FileRunStore,
FileWorkflowArtifactStore,
WorkflowArtifact,
WorkflowDeployment,
)
from wf_authoring import NodeSpec
from wf_core import InterruptRequest, RunState, RunStatus
from wf_platform import CapabilitySource
from wf_scheduling.history import FileScheduleHistoryRecorder, HistoryEntry
from wf_scheduling.models import PendingCandidate
from wf_scheduling.store import (
FileScheduleStore,
ScheduleExistsError,
StaleScheduleRevisionError,
)
def ts(y: int, mo: int, d: int, h: int = 0, mi: int = 0) -> datetime:
return datetime(y, mo, d, h, mi, tzinfo=UTC)
def _cron() -> dict[str, Any]:
return {"kind": "cron", "expression": "* * * * *", "timezone": "UTC"}
def _oneshot(at: datetime) -> dict[str, Any]:
return {"kind": "oneshot", "at": at.isoformat()}
def _literal_binding(target: str, value: Any) -> dict[str, Any]:
return {"target": target, "expression": {"kind": "literal", "value": value}}
def _occurrence_binding(target: str, field: str) -> dict[str, Any]:
return {"target": target, "expression": {"kind": "occurrence", "field": field}}
class DummyEvents:
def record_event(self, event: object) -> None:
pass
def record_workflow_event(
self,
event_type: str,
*,
capability_id: str,
payload: dict[str, Any],
) -> None:
pass
class EmptySpecProvider:
@property
def capability_sources(self) -> dict[str, CapabilitySource]:
return {}
def get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
raise KeyError(f"unknown capability {qualified_name!r}")
class NeverRuntime:
"""Context runtime double: schedule admin never executes workflows."""
async def run_workflow_from_plan(self, *args: Any, **kwargs: Any) -> Any:
raise AssertionError("schedule admin must not execute workflows")
async def resume_workflow_from_plan(self, *args: Any, **kwargs: Any) -> Any:
raise AssertionError("schedule admin must not resume workflows")
class CompletingRuntime:
"""Resume-only fake completing one interrupted run."""
async def run_workflow_from_plan(self, *args: Any, **kwargs: Any) -> Any:
raise AssertionError("test must not start new workflow runs")
async def resume_workflow_from_plan(
self,
plan: Any,
run: RunState,
*,
resume_payload: dict[str, Any],
resume_outcome: str,
deployment: Any = None,
artifact: Any = None,
saved_subgraph_tree: Any = None,
) -> RunState:
return RunState(
workflow_name=plan.name,
status=RunStatus.COMPLETED,
workflow_input=dict(run.workflow_input),
state={},
outcome=resume_outcome,
output=dict(resume_payload),
)
def _artifact(
artifact_id: str = "sched-art",
input_schema: dict[str, Any] | None = None,
) -> WorkflowArtifact:
schema = (
input_schema
if input_schema is not None
else {"type": "object", "properties": {}}
)
return WorkflowArtifact(
id=artifact_id,
version=1,
title="Sched",
input_schema=schema,
output_schema={"type": "object", "properties": {}},
outcomes=("ok", "submitted"),
plan={
"name": artifact_id,
"input_schema": schema,
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"outcomes": ["ok", "submitted"],
"start": "end_submitted",
"nodes": [{"id": "end_submitted", "type": "end", "outcome": "submitted"}],
"edges": [],
},
)
def _deployment(
deployment_id: str = "dep.personal", artifact_id: str = "sched-art"
) -> WorkflowDeployment:
return WorkflowDeployment(
id=deployment_id,
artifact_id=artifact_id,
artifact_version=1,
bindings=[],
)
def _context(
artifact_store: FileWorkflowArtifactStore,
run_store: FileRunStore,
sched_store: FileScheduleStore | None,
runtime: Any | None = None,
) -> WorkflowOperationContext:
return WorkflowOperationContext(
artifact_store=artifact_store,
draft_workspace_store=None,
run_store=run_store,
events=DummyEvents(),
specs=EmptySpecProvider(),
runtime=runtime or NeverRuntime(),
live_sources=None,
schedule_store=sched_store,
)
def _harness(
root: Path,
) -> tuple[
WorkflowScheduleApi,
FileScheduleStore,
FileRunStore,
FileWorkflowArtifactStore,
WorkflowOperationContext,
]:
artifact_store = FileWorkflowArtifactStore(root)
artifact_store.save_artifact(_artifact())
artifact_store.save_deployment(_deployment())
run_store = FileRunStore(root)
sched_store = FileScheduleStore(root)
context = _context(artifact_store, run_store, sched_store)
return WorkflowScheduleApi(context), sched_store, run_store, artifact_store, context
def _env(artifact_store: FileWorkflowArtifactStore) -> Any:
deployment = artifact_store.get_deployment("dep.personal")
artifact = artifact_store.get_artifact(
deployment.artifact_id, deployment.artifact_version
)
return create_pinned_environment(
deployment=deployment,
artifact=artifact,
tree=SavedSubgraphTree(artifacts_by_ref={}, diagnostics=[]),
)
def _admit_owned(
run_store: FileRunStore,
artifact_store: FileWorkflowArtifactStore,
run_id: str,
intended: datetime,
schedule_id: str = "s",
resolved_input: dict[str, Any] | None = None,
) -> Any:
admission = persist_admission(
store=run_store,
run_id=run_id,
environment=_env(artifact_store),
resolved_input=dict(resolved_input or {}),
max_steps=None,
scheduled_at=intended,
schedule_id=schedule_id,
schedule_revision=1,
)
materialize_admitted_view(store=run_store, admission=admission)
return admission
def _interrupted_state() -> RunState:
return RunState(
workflow_name="sched",
status=RunStatus.INTERRUPTED,
workflow_input={},
state={},
interrupt=InterruptRequest(
id="interrupt:approval",
frame_id="root",
node_id="approval",
kind="approval",
payload={"question": "continue?"},
),
)
def _history_rows(
sched_store: FileScheduleStore, schedule_id: str
) -> list[dict[str, Any]]:
page = sched_store.list_occurrences(schedule_id, limit=100)
rows = page["occurrences"]
assert isinstance(rows, list)
return rows
async def test_create_get_list_round_trip_with_defaults(tmp_path: Path) -> None:
api, sched_store, _, _, _ = _harness(tmp_path / "round_trip")
created = await api.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
assert created["exhausted"] is False
assert created["blocked_reason"] is None
assert created["created_at"] == created["updated_at"]
fetched = await api.get_schedule(schedule_id="s")
assert fetched["id"] == "s"
assert fetched["revision"] == 1
listed = await api.list_schedules()
assert [row["id"] for row in listed["schedules"]] == ["s"]
async def test_create_duplicate_rejected_including_deleted(tmp_path: Path) -> None:
api, _, _, _, _ = _harness(tmp_path / "dup")
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
with pytest.raises(ScheduleExistsError):
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
await api.delete_schedule(schedule_id="s")
# Deleted ids are never reusable: old history stays annexed to the id.
with pytest.raises(ScheduleExistsError):
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
async def test_create_unknown_deployment_keyerror(tmp_path: Path) -> None:
api, _, _, _, _ = _harness(tmp_path / "unknown_dep")
with pytest.raises(KeyError):
await api.create_schedule(
schedule_id="s", deployment_id="missing.dep", trigger=_cron()
)
@pytest.mark.parametrize(
"trigger",
[
{"kind": "cron", "expression": "not-a-cron", "timezone": "UTC"},
{"kind": "cron", "expression": "* * *", "timezone": "UTC"},
{"kind": "cron", "expression": "* * * * *", "timezone": "Mars/Olympus"},
{"kind": "hourly"},
{"kind": "oneshot", "at": "2026-09-08T12:00:00"},
],
)
async def test_create_bad_trigger_valueerror(
tmp_path: Path, trigger: dict[str, Any]
) -> None:
api, _, _, _, _ = _harness(tmp_path / "bad_trigger")
with pytest.raises(ValueError):
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=trigger
)
@pytest.mark.parametrize(
"bindings",
[
[{"target": "x"}],
[{"expression": {"kind": "literal", "value": 1}}],
[{"target": "x", "expression": {"kind": "path", "path": "input.x"}}],
[{"target": "x", "expression": {"kind": "occurrence", "field": "nope"}}],
],
)
async def test_create_bad_bindings_valueerror(
tmp_path: Path, bindings: list[dict[str, Any]]
) -> None:
api, _, _, _, _ = _harness(tmp_path / "bad_bindings")
with pytest.raises(ValueError):
await api.create_schedule(
schedule_id="s",
deployment_id="dep.personal",
trigger=_cron(),
input_bindings=bindings,
)
@pytest.mark.parametrize("max_steps", [0, -3])
async def test_create_bad_max_steps_valueerror(tmp_path: Path, max_steps: int) -> None:
api, _, _, _, _ = _harness(tmp_path / "bad_steps")
with pytest.raises(ValueError):
await api.create_schedule(
schedule_id="s",
deployment_id="dep.personal",
trigger=_cron(),
max_steps=max_steps,
)
async def test_create_sample_schema_failure_valueerror(tmp_path: Path) -> None:
api, _, _, artifact_store, _ = _harness(tmp_path / "sample")
artifact_store.save_artifact(
_artifact(
artifact_id="strict-art",
input_schema={
"type": "object",
"properties": {"msg": {"type": "string"}},
"required": ["msg"],
},
)
)
artifact_store.save_deployment(_deployment("strict.personal", "strict-art"))
# A literal violating the root input schema fails sample validation.
with pytest.raises(ValueError):
await api.create_schedule(
schedule_id="bad",
deployment_id="strict.personal",
trigger=_cron(),
input_bindings=[_literal_binding("msg", 123)],
)
created = await api.create_schedule(
schedule_id="good",
deployment_id="strict.personal",
trigger=_cron(),
input_bindings=[_literal_binding("msg", "hi")],
)
assert created["id"] == "good"
async def test_update_happy_path_only_future_work(tmp_path: Path) -> None:
api, sched_store, run_store, artifact_store, _ = _harness(tmp_path / "update")
created = await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
intended = ts(2026, 9, 8, 12, 0)
sched_store.save_candidate(
PendingCandidate(schedule_id="s", intended_at=intended, revision=1),
schedule_id="s",
)
# Admit before the edit: the frozen invocation must survive it.
run_id = run_store.allocate_run_id()
_admit_owned(
run_store, artifact_store, run_id, intended, resolved_input={"note": "before"}
)
before = datetime.now(UTC)
updated = await api.update_schedule(
schedule_id="s",
expected_revision=1,
trigger=_oneshot(intended + timedelta(hours=1)),
input_bindings=[_occurrence_binding("note", "scheduled_at")],
overlap="parallel",
max_active_runs=3,
max_steps=50,
)
assert updated["revision"] == 2
assert updated["created_at"] == created["created_at"]
assert updated["trigger"]["kind"] == "oneshot"
assert updated["overlap"] == "parallel"
assert updated["max_active_runs"] == 3
assert updated["max_steps"] == 50
# Edit side effects mirror Scheduler.edit_schedule.
assert sched_store.get_candidate("s") is None
rows = _history_rows(sched_store, "s")
superseded = [row for row in rows if row["kind"] == "superseded"]
assert len(superseded) == 1
assert superseded[0]["reason"] == "schedule-edit"
assert superseded[0]["revision"] == 2
consumed_after_update = sched_store.get_consumed("s")
assert consumed_after_update is not None
assert consumed_after_update >= before
# The admitted run keeps its pinned invocation: edits only affect
# future admissions.
admission = run_store.get_admission(run_id)
assert admission.resolved_input == {"note": "before"}
assert admission.schedule_revision == 1
assert run_store.get_run(run_id).status.value == "admitted"
async def test_update_stale_revision_rejected(tmp_path: Path) -> None:
api, _, _, _, _ = _harness(tmp_path / "stale")
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
with pytest.raises(StaleScheduleRevisionError):
await api.update_schedule(schedule_id="s", expected_revision=99)
async def test_unknown_schedule_keyerror(tmp_path: Path) -> None:
api, _, _, _, _ = _harness(tmp_path / "unknown_sched")
with pytest.raises(KeyError):
await api.get_schedule(schedule_id="missing")
with pytest.raises(KeyError):
await api.update_schedule(schedule_id="missing", expected_revision=1)
with pytest.raises(KeyError):
await api.pause_schedule(schedule_id="missing")
with pytest.raises(KeyError):
await api.resume_schedule(schedule_id="missing")
with pytest.raises(KeyError):
await api.delete_schedule(schedule_id="missing")
with pytest.raises(KeyError):
await api.list_schedule_occurrences(schedule_id="missing")
async def test_pause_resume_delete_transitions(tmp_path: Path) -> None:
api, sched_store, _, _, _ = _harness(tmp_path / "transitions")
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
assert sched_store.get_consumed("s") is None
intended = ts(2026, 9, 8, 12, 0)
sched_store.save_candidate(
PendingCandidate(schedule_id="s", intended_at=intended, revision=1),
schedule_id="s",
)
before_pause = datetime.now(UTC)
paused = await api.pause_schedule(schedule_id="s")
assert paused["paused"] is True
assert paused["revision"] == 1
# Pause clears the candidate and advances the watermark past the pause
# (no history write: the span is never backfilled).
assert sched_store.get_candidate("s") is None
consumed = sched_store.get_consumed("s")
assert consumed is not None and consumed >= before_pause
assert _history_rows(sched_store, "s") == []
sched_store.save_candidate(
PendingCandidate(schedule_id="s", intended_at=intended, revision=1),
schedule_id="s",
)
before_resume = datetime.now(UTC)
resumed = await api.resume_schedule(schedule_id="s")
assert resumed["paused"] is False
assert sched_store.get_candidate("s") is None
resumed_consumed = sched_store.get_consumed("s")
assert resumed_consumed is not None
assert resumed_consumed >= before_resume
assert resumed_consumed >= consumed
assert _history_rows(sched_store, "s") == []
sched_store.save_candidate(
PendingCandidate(schedule_id="s", intended_at=intended, revision=1),
schedule_id="s",
)
deleted = await api.delete_schedule(schedule_id="s")
assert deleted["deleted"] is True
assert sched_store.get_candidate("s") is None
# Delete advances nothing: the watermark is exactly the resume value.
assert sched_store.get_consumed("s") == resumed_consumed
assert (await api.list_schedules())["schedules"] == []
listed = await api.list_schedules(include_deleted=True)
assert [row["id"] for row in listed["schedules"]] == ["s"]
async def test_delete_preserves_runs_and_history(tmp_path: Path) -> None:
api, sched_store, run_store, artifact_store, _ = _harness(tmp_path / "del_keep")
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
intended = ts(2026, 9, 8, 12, 0)
run_id = run_store.allocate_run_id()
_admit_owned(run_store, artifact_store, run_id, intended)
FileScheduleHistoryRecorder(sched_store).record(
HistoryEntry(
schedule_id="s",
kind="admitted",
resolved_at=intended,
run_id=run_id,
revision=1,
reason="rev=1",
created_at=intended,
)
)
await api.delete_schedule(schedule_id="s")
assert run_store.get_run(run_id).id == run_id
page = await api.list_schedule_occurrences(schedule_id="s")
assert [row["run_id"] for row in page["occurrences"]] == [run_id]
with pytest.raises(ScheduleExistsError):
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
async def test_occurrences_pagination_and_validation(tmp_path: Path) -> None:
api, sched_store, _, _, _ = _harness(tmp_path / "pages")
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
base = ts(2026, 9, 8, 12, 0)
recorder = FileScheduleHistoryRecorder(sched_store)
for index in range(5):
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,
)
)
first = await api.list_schedule_occurrences(schedule_id="s", limit=2)
assert first["total"] == 5
assert first["cursor"] is None
assert first["limit"] == 2
assert len(first["occurrences"]) == 2
assert first["next_cursor"] is not None
second = await api.list_schedule_occurrences(
schedule_id="s", cursor=first["next_cursor"], limit=2
)
assert second["total"] == 5
assert second["cursor"] == first["next_cursor"]
assert len(second["occurrences"]) == 2
assert {row["run_id"] for row in first["occurrences"]}.isdisjoint(
{row["run_id"] for row in second["occurrences"]}
)
with pytest.raises(ValueError):
await api.list_schedule_occurrences(schedule_id="s", limit=0)
with pytest.raises(ValueError):
await api.list_schedule_occurrences(schedule_id="s", limit=101)
with pytest.raises(ValueError):
await api.list_schedule_occurrences(schedule_id="s", cursor="bogus")
async def test_occurrences_pending_synthesis_first_page_only(tmp_path: Path) -> None:
api, sched_store, _, _, _ = _harness(tmp_path / "pending")
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
base = ts(2026, 9, 8, 12, 0)
recorder = FileScheduleHistoryRecorder(sched_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)
sched_store.save_candidate(
PendingCandidate(schedule_id="s", intended_at=intended, revision=1),
schedule_id="s",
)
first = await api.list_schedule_occurrences(schedule_id="s", limit=2)
assert first["total"] == 4
pending = first["occurrences"][0]
assert pending["kind"] == "pending"
assert pending["schedule_id"] == "s"
assert pending["occurrence_id"] == f"s|{intended.isoformat()}"
assert datetime.fromisoformat(pending["resolved_at"]) == intended
assert pending["revision"] == 1
assert pending["reason"] == ""
assert pending["run_id"] is None
assert first["occurrences"][1]["kind"] == "admitted"
assert first["next_cursor"] is not None
later = await api.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"])
sched_store.save_candidate(None, schedule_id="s")
plain = await api.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_inspect_admitted_run_without_fabrication(tmp_path: Path) -> None:
api, sched_store, run_store, artifact_store, context = _harness(
tmp_path / "admitted"
)
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
intended = ts(2026, 9, 8, 12, 0)
run_id = run_store.allocate_run_id()
_admit_owned(run_store, artifact_store, run_id, intended)
summary = await WorkflowRunApi(context).inspect_run(run_id=run_id)
assert summary["status"] == "admitted"
assert summary["run_id"] == run_id
assert summary["trace_count"] == 0
assert summary["output"] is None
assert summary["interrupt"] is None
assert "trace" not in summary
with pytest.raises(ValueError):
await WorkflowRunApi(context).read_run_trace(
run_id=run_id, trace_range=TraceRange(start=0, limit=1)
)
async def test_list_runs_includes_admitted(tmp_path: Path) -> None:
_, _, run_store, artifact_store, context = _harness(tmp_path / "list_adm")
run_id = run_store.allocate_run_id()
_admit_owned(run_store, artifact_store, run_id, ts(2026, 9, 8, 12, 0))
runs = WorkflowRunApi(context)
filtered = await runs.list_runs(status="admitted")
assert filtered["total"] == 1
assert filtered["runs"][0]["run_id"] == run_id
assert filtered["runs"][0]["status"] == "admitted"
unfiltered = await runs.list_runs()
assert unfiltered["total"] == 1
async def test_schedule_owned_resume_then_recovery_reconciles(tmp_path: Path) -> None:
root = tmp_path / "reconcile"
api, sched_store, run_store, artifact_store, context = _harness(root)
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
intended = ts(2026, 9, 8, 12, 0)
run_id = run_store.allocate_run_id()
admission = _admit_owned(run_store, artifact_store, run_id, intended)
persist_stopped_run(
store=run_store,
environment=admission.environment,
run=_interrupted_state(),
run_id=run_id,
)
resume_context = _context(
artifact_store, run_store, sched_store, runtime=CompletingRuntime()
)
resumed = await WorkflowRunApi(resume_context).resume_run(
run_id=run_id,
resume_payload={"approved": True},
resume_outcome="submitted",
)
assert resumed["status"] == "completed"
from wf_scheduling import recovery as sched_recovery
from wf_scheduling.ownership import SchedulerOwnership
ownership = SchedulerOwnership(root, owner="test").acquire()
try:
diags = sched_recovery.recover(
schedule_store=sched_store,
run_store=run_store,
now=intended + timedelta(minutes=5),
ownership=ownership,
)
finally:
ownership.release()
assert any("terminal-reconciled" in message for message in diags)
page = await api.list_schedule_occurrences(schedule_id="s")
completed = [row for row in page["occurrences"] if row["kind"] == "completed"]
assert len(completed) == 1
assert completed[0]["run_id"] == run_id
assert completed[0]["checkpoint_id"] == f"{run_id}.000002"
async def test_schedule_api_without_store_raises_keyerror(tmp_path: Path) -> None:
root = tmp_path / "no_store"
artifact_store = FileWorkflowArtifactStore(root)
artifact_store.save_artifact(_artifact())
artifact_store.save_deployment(_deployment())
run_store = FileRunStore(root)
context = _context(artifact_store, run_store, None)
api = WorkflowScheduleApi(context)
with pytest.raises(KeyError):
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
with pytest.raises(KeyError):
await api.get_schedule(schedule_id="s")
with pytest.raises(KeyError):
await api.list_schedules()
with pytest.raises(KeyError):
await api.update_schedule(schedule_id="s", expected_revision=1)
with pytest.raises(KeyError):
await api.pause_schedule(schedule_id="s")
with pytest.raises(KeyError):
await api.resume_schedule(schedule_id="s")
with pytest.raises(KeyError):
await api.delete_schedule(schedule_id="s")
with pytest.raises(KeyError):
await api.list_schedule_occurrences(schedule_id="s")
facade = WorkflowApi(context)
with pytest.raises(KeyError):
await facade.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
async def test_facade_and_durable_context_delegate(tmp_path: Path) -> None:
_, sched_store, run_store, artifact_store, _ = _harness(tmp_path / "facade")
bare = _context(artifact_store, run_store, None)
explicit = WorkflowApi(bare, schedule_store=sched_store)
created = await explicit.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
assert created["id"] == "s"
assert (await explicit.get_schedule(schedule_id="s"))["revision"] == 1
assert [row["id"] for row in (await explicit.list_schedules())["schedules"]] == [
"s"
]
assert (await explicit.pause_schedule(schedule_id="s"))["paused"] is True
assert (await explicit.resume_schedule(schedule_id="s"))["paused"] is False
assert (await explicit.delete_schedule(schedule_id="s"))["deleted"] is True
stored_context = _context(artifact_store, run_store, sched_store)
via_durable = durable_workflow_api(stored_context)
assert (await via_durable.get_schedule(schedule_id="s"))["deleted"] is True
async def test_local_server_schedules_flag(tmp_path: Path) -> None:
from wf_server import build_local_static_workflow_server
plain = build_local_static_workflow_server(tmp_path / "plain")
assert not (tmp_path / "plain" / "schedules").exists()
with pytest.raises(KeyError):
await plain.api.get_schedule(schedule_id="s")
server = build_local_static_workflow_server(tmp_path / "srv", schedules=True)
assert (tmp_path / "srv" / "schedules").is_dir()
server.stores.artifact_store.save_artifact(_artifact())
server.stores.artifact_store.save_deployment(_deployment())
created = await server.api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
assert created["id"] == "s"
assert (await server.api.get_schedule(schedule_id="s"))["revision"] == 1