sched: schedule administration API, admitted inspection, occurrence pages (T13)
This commit is contained in:
+14
-1
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user