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 .listing import matches_query, paged_list_payload
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 .operation_context import (
WorkflowEventRecorder,
@@ -35,6 +41,7 @@ from .operation_context import (
from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id
from .runs import WorkflowRunApi
from .runtime_dependencies import RuntimeDependencies, resolve_runtime_dependencies
from .schedules import WorkflowScheduleApi
from .service import WorkflowApi
from .source_admin import WorkflowSourceAdminApi
from .source_refs import SourceResourceRef
@@ -53,6 +60,7 @@ from .surface import (
WorkflowDeploymentSurface,
WorkflowDraftSurface,
WorkflowRunSurface,
WorkflowScheduleSurface,
WorkflowSourceAdminSurface,
WorkflowSourceRegistrySurface,
)
@@ -78,6 +86,7 @@ __all__ = [
"AuthRecord",
"AuthStore",
"CapabilityStepUpdate",
"ListSchedulesResult",
"MissingDecision",
"MissingDecisionKind",
"NextActionPatchExample",
@@ -85,9 +94,11 @@ __all__ = [
"NextActions",
"OutcomeCandidate",
"OutcomeCandidateKind",
"OccurrencePage",
"RawWorkflowPlan",
"RouteSource",
"RuntimeDependencies",
"ScheduleResult",
"SourceResourceRef",
"TraceRange",
"WorkflowAdminApi",
@@ -111,6 +122,8 @@ __all__ = [
"WorkflowRunApi",
"WorkflowRunSurface",
"WorkflowRuntimeRunner",
"WorkflowScheduleApi",
"WorkflowScheduleSurface",
"WorkflowSourceAdminApi",
"WorkflowSourceAdminSurface",
"WorkflowSourceRegistryApi",
+9 -2
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from .operation_context import WorkflowOperationContext
from .service import WorkflowApi
from .stores import WorkflowStores
@@ -38,10 +40,15 @@ def durable_workflow_api(
context: WorkflowOperationContext,
*,
drafts: bool = False,
schedule_store: Any | None = None,
) -> 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)
return WorkflowApi(context, drafts=drafts)
return WorkflowApi(context, drafts=drafts, schedule_store=schedule_store)
__all__ = ["durable_workflow_api", "require_workflow_stores"]
+4
View File
@@ -101,6 +101,7 @@ from .runs import (
TraceEntryPayload,
WorkflowRefPayload,
)
from .schedules import ListSchedulesResult, OccurrencePage, ScheduleResult
from .source_registry import (
ApplyRegistryChangesResult,
InspectRegistryEntryResult,
@@ -186,11 +187,13 @@ __all__ = [
"ListRunsResult",
"ListRegistryEntriesResult",
"ListSourcesResult",
"ListSchedulesResult",
"NextActionPatchExamplePayload",
"NextActionsPayload",
"NodeSpecCapabilityDetail",
"NodeSpecCapabilitySummary",
"NodeSpecInventoryPayload",
"OccurrencePage",
"PageMetadataPayload",
"PatchDraftResult",
"PatchedDraftInvalidResult",
@@ -208,6 +211,7 @@ __all__ = [
"RequiredCapabilityPayload",
"RemoveRegistryEntryResult",
"SaveArtifactResult",
"ScheduleResult",
"ValidateArtifactPlanResult",
"SavedDraftArtifactResult",
"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
runtime: WorkflowRuntimeRunner
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__ = [
+26 -5
View File
@@ -337,13 +337,34 @@ class WorkflowRunApi:
}
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
fabricated trace/output); checkpoint-free inspection arrives with
the scheduling administration surface (T13).
Admitted runs with no stopped checkpoint report their durable
admission truthfully (status admitted, no checkpoint id, no
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
return _run_payload(
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,
ListDraftWorkspacesResult,
ListRunsResult,
ListSchedulesResult,
OccurrencePage,
PatchDraftResult,
RawWorkflowPlan,
RunResult,
@@ -46,6 +48,7 @@ from .models import (
SaveArtifactResult,
SavedDraftArtifactResult,
SaveDeploymentResult,
ScheduleResult,
ValidateArtifactPlanResult,
ValidateDeploymentResult,
ValidateDraftResult,
@@ -54,6 +57,7 @@ from .models import (
)
from .operation_context import WorkflowOperationContext
from .runs import TraceRangeLike, WorkflowRunApi
from .schedules import WorkflowScheduleApi
def _authoring_schema(
@@ -105,6 +109,7 @@ class WorkflowApi:
context: WorkflowOperationContext,
*,
drafts: bool = False,
schedule_store: Any | None = None,
) -> None:
self.context = context
self.capabilities = WorkflowCapabilityApi(context, drafts=drafts)
@@ -120,6 +125,10 @@ class WorkflowApi:
self.artifacts = WorkflowArtifactApi(context, drafts=drafts)
self.deployments = WorkflowDeploymentApi(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:
"""Return the draft service for an explicitly draft-enabled API."""
@@ -1089,3 +1098,109 @@ class WorkflowApi:
run_id=run_id,
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,
ListRegistryEntriesResult,
ListRunsResult,
ListSchedulesResult,
ListSourcesResult,
OccurrencePage,
PatchDraftResult,
RegistryEntryMutationResult,
RemoveRegistryEntryResult,
@@ -44,6 +46,7 @@ from .models import (
RunTraceResult,
SaveArtifactResult,
SaveDeploymentResult,
ScheduleResult,
SourceDiagnosisResult,
ValidateArtifactPlanResult,
ValidateDeploymentResult,
@@ -543,12 +546,91 @@ class WorkflowRunSurface(Protocol):
) -> 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(
WorkflowCapabilitySurface,
WorkflowDraftSurface,
WorkflowArtifactSurface,
WorkflowDeploymentSurface,
WorkflowRunSurface,
WorkflowScheduleSurface,
Protocol,
):
"""Public workflow operation surface shared by local and remote adapters."""
@@ -660,6 +742,7 @@ __all__ = [
"WorkflowDeploymentSurface",
"WorkflowDraftSurface",
"WorkflowRunSurface",
"WorkflowScheduleSurface",
"WorkflowSourceAdminSurface",
"WorkflowSourceRegistrySurface",
]
+13 -2
View File
@@ -299,8 +299,14 @@ def build_local_static_workflow_server(
*,
extra_sources: Mapping[str, CapabilitySource] | None = None,
drafts: bool = False,
schedules: bool = False,
) -> 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))
stores = file_workflow_stores(config.store_root, drafts=drafts)
events = InMemoryWorkflowEventRecorder()
@@ -324,7 +330,12 @@ def build_local_static_workflow_server(
runtime=runtime,
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)
admin = WorkflowAdminApi(
connections=EmptyWorkflowConnectionProvider(),