Files
next-tjwater-server/app/domain/schemas/operational_task.py
T

759 lines
26 KiB
Python

"""Contracts for project-scoped operational tasks and human-approved actions."""
from __future__ import annotations
import re
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
WorkbenchDateTime,
WorkOrderActivity,
)
TaskType = Literal["scheduled_flow", "external_work_order", "event_investigation"]
TaskStatus = Literal[
"queued", "running", "waiting_human", "completed", "failed", "cancelled"
]
TaskLifecycleStatus = Literal[
"open", "waiting_human", "completed", "failed", "cancelled"
]
OperationalTaskRunStatus = Literal[
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
]
TaskPriority = Literal["normal", "urgent"]
TaskSourceKind = Literal["schedule", "external_system", "operator", "agent"]
TriggerKind = Literal["schedule", "external_sync", "event", "manual"]
EvidenceKind = Literal["system_data", "external_feedback", "human_input", "synthetic"]
PlanNodeStatus = Literal[
"pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"
]
ActionKind = Literal["create_work_order", "issue_command"]
ApprovalStatus = Literal["pending", "approved", "rejected"]
ExecutionStatus = Literal[
"not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"
]
class TaskSource(StrictModel):
kind: TaskSourceKind
external_id: NonEmptyStr | None = None
external_revision: PositiveInt | None = None
event_kind: NonEmptyStr | None = None
deleted_at: WorkbenchDateTime | None = None
class TaskTrigger(StrictModel):
kind: TriggerKind
occurred_at: WorkbenchDateTime
detail: NonEmptyStr
class OperationalTaskCreate(StrictModel):
request_id: UUID
task_type: TaskType
workflow: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
priority: TaskPriority = "normal"
source: TaskSource
trigger: TaskTrigger
scheduled_for: WorkbenchDateTime | None = None
agent_session_id: NonEmptyStr | None = None
class ExecutionPlanNode(StrictModel):
id: NonEmptyStr
title: NonEmptyStr
owner: Literal["agent", "human"]
status: PlanNodeStatus
detail: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
kind: Literal["activity", "decision", "parallel_split", "parallel_join", "gate"] | None = None
selected_edge_id: NonEmptyStr | None = None
gate_ref: UUID | None = None
revisits_node_id: NonEmptyStr | None = None
class ExecutionPlanEdge(StrictModel):
id: NonEmptyStr | None = None
from_node: NonEmptyStr = Field(alias="from")
to_node: NonEmptyStr = Field(alias="to")
condition: NonEmptyStr | None = None
class ExecutionPlanInput(StrictModel):
schema_name: Literal["execution-plan@1", "execution-plan@2"] = Field(alias="schema")
title: NonEmptyStr
reason: NonEmptyStr
nodes: Annotated[list[ExecutionPlanNode], Field(min_length=1, max_length=100)]
edges: Annotated[list[ExecutionPlanEdge], Field(max_length=300)] = Field(
default_factory=list
)
@model_validator(mode="after")
def validate_graph(self):
node_ids = [node.id for node in self.nodes]
if len(set(node_ids)) != len(node_ids):
raise ValueError("execution plan node ids must be unique")
known = set(node_ids)
graph = {node_id: [] for node_id in node_ids}
indegree = {node_id: 0 for node_id in node_ids}
seen_edges: set[tuple[str, str]] = set()
edge_ids: set[str] = set()
for edge in self.edges:
pair = (edge.from_node, edge.to_node)
if edge.from_node not in known or edge.to_node not in known:
raise ValueError("execution plan edges must reference known nodes")
if edge.from_node == edge.to_node or pair in seen_edges:
raise ValueError("execution plan edges must be unique and non-reflexive")
seen_edges.add(pair)
if self.schema_name == "execution-plan@2":
if edge.id is None or edge.id in edge_ids:
raise ValueError("v2 execution plan edge ids must be unique")
edge_ids.add(edge.id)
graph[edge.from_node].append(edge.to_node)
indegree[edge.to_node] += 1
pending = [node_id for node_id, value in indegree.items() if value == 0]
visited = 0
while pending:
current = pending.pop()
visited += 1
for target in graph[current]:
indegree[target] -= 1
if indegree[target] == 0:
pending.append(target)
if visited != len(node_ids):
raise ValueError("execution plan must be acyclic")
if self.schema_name == "execution-plan@2" and sum(
not any(edge.to_node == node_id for edge in self.edges) for node_id in node_ids
) != 1:
raise ValueError("v2 execution plan requires one start node")
if self.schema_name == "execution-plan@2":
by_id = {node.id: node for node in self.nodes}
for node in self.nodes:
if node.kind is None:
raise ValueError("v2 execution plan nodes require kind")
outgoing = [edge for edge in self.edges if edge.from_node == node.id]
incoming = [edge for edge in self.edges if edge.to_node == node.id]
if node.kind == "decision":
if len(outgoing) < 2 or any(not edge.condition for edge in outgoing):
raise ValueError("decision requires at least two labeled routes")
if node.selected_edge_id is not None and node.selected_edge_id not in {edge.id for edge in outgoing}:
raise ValueError("decision selected_edge_id must reference an outgoing route")
if node.status == "completed" and node.selected_edge_id is None:
raise ValueError("completed decision requires a selected route")
if node.selected_edge_id is not None:
for edge in outgoing:
target_status = by_id[edge.to_node].status
if edge.id == node.selected_edge_id and target_status in {"skipped", "cancelled"}:
raise ValueError("selected route cannot be skipped")
if edge.id != node.selected_edge_id and target_status in {"running", "completed", "waiting"}:
raise ValueError("unchosen route cannot be active")
elif node.selected_edge_id is not None:
raise ValueError("only decision nodes may select routes")
if node.kind not in {"decision", "parallel_split"} and len(outgoing) > 1:
raise ValueError("only decision or parallel split may branch")
if node.kind == "parallel_split" and len(outgoing) < 2:
raise ValueError("parallel split requires at least two routes")
if node.kind == "parallel_join" and len(incoming) < 2:
raise ValueError("parallel join requires at least two inputs")
if node.kind == "gate" and node.gate_ref is None:
raise ValueError("gate requires a persisted reference")
if node.kind != "gate" and node.gate_ref is not None:
raise ValueError("only gate nodes may have gate_ref")
if node.revisits_node_id is not None and node.revisits_node_id not in by_id:
raise ValueError("revisited node must exist in the plan")
return self
class EvidenceInput(StrictModel):
evidence_id: NonEmptyStr
kind: EvidenceKind
summary: NonEmptyStr
source_label: NonEmptyStr
source_ref: NonEmptyStr | None = None
observed_at: WorkbenchDateTime
class EvidenceRecord(EvidenceInput):
recorded_at: WorkbenchDateTime
recorded_by: NonEmptyStr
has_snapshot: bool = False
class StoredEvidenceRecord(EvidenceRecord):
data_snapshot: TaskDataSnapshot | None = None
class InvestigationDataGap(StrictModel):
gap_id: NonEmptyStr
node_id: NonEmptyStr | None = None
missing_data: NonEmptyStr
scope: NonEmptyStr
impact: NonEmptyStr
required_data: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(min_length=1)
blocking: StrictBool
class InvestigationDiagramNodeRef(StrictModel):
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
label: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
class InvestigationDiagram(StrictModel):
title: NonEmptyStr
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
@model_validator(mode="after")
def validate_description(self):
if not re.match(r"^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b", self.mermaid):
raise ValueError("investigation diagrams must be Mermaid flowcharts")
if "```" in self.mermaid or "%%{" in self.mermaid or re.search(r"(?:^|[;\n])\s*click\b", self.mermaid):
raise ValueError("diagram descriptions cannot contain fences, directives or click commands")
ids = [node.node_id for node in self.node_refs]
if len(ids) != len(set(ids)):
raise ValueError("diagram node references must be unique")
return self
class InvestigationReport(StrictModel):
"""Read model: absent historical facts remain unknown, never inferred."""
title: NonEmptyStr
current_conclusion: NonEmptyStr
conclusion_boundary: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
recommendations: list[NonEmptyStr] = Field(default_factory=list)
pending_items: list[NonEmptyStr] = Field(default_factory=list)
stage: Literal["interim", "final"] | None = None
outcome: Literal["concluded", "insufficient_data"] | None = None
stop_reason: NonEmptyStr | None = None
data_gaps: list[InvestigationDataGap] | None = None
diagram: InvestigationDiagram | None = None
@model_validator(mode="after")
def validate_result(self):
if self.outcome is None:
if self.stage is not None or self.stop_reason is not None or self.data_gaps is not None:
raise ValueError("structured report fields must be supplied together")
return self
if self.stage is None or self.data_gaps is None:
raise ValueError("structured reports require stage and data_gaps")
ids = [gap.gap_id for gap in self.data_gaps]
if len(ids) != len(set(ids)):
raise ValueError("data gap ids must be unique")
blocking = any(gap.blocking for gap in self.data_gaps)
if self.outcome == "insufficient_data":
if self.stage != "final" or not self.stop_reason or not blocking:
raise ValueError("insufficient data requires a final report, stop reason and blocking gap")
if self.recommendations or self.pending_items:
raise ValueError("insufficient data ends the investigation without downstream recommendations or pending work")
elif self.stop_reason is not None or blocking:
raise ValueError("concluded report cannot contain a stop reason or blocking gap")
return self
class InvestigationReportInput(InvestigationReport):
"""Write model: every new report explicitly declares its business result."""
stage: Literal["interim", "final"]
outcome: Literal["concluded", "insufficient_data"]
data_gaps: list[InvestigationDataGap]
class ExecutionPlanSnapshot(StrictModel):
revision: PositiveInt
execution_generation: PositiveInt | None = None
source_session_id: NonEmptyStr
published_at: WorkbenchDateTime
published_by: NonEmptyStr
plan: ExecutionPlanInput
class InvestigationReportRevision(StrictModel):
revision: PositiveInt
execution_generation: PositiveInt | None = None
source_session_id: NonEmptyStr
published_at: WorkbenchDateTime
published_by: NonEmptyStr
report: InvestigationReport
class InvestigationReportPage(StrictModel):
items: list[InvestigationReportRevision]
next_offset: int | None = Field(default=None, ge=0)
class InvestigationReportReference(StrictModel):
execution_generation: PositiveInt
revision: PositiveInt
class AgentArtifactsCommand(StrictModel):
request_id: UUID
source_session_id: NonEmptyStr
run_id: UUID | None = None
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
plan: ExecutionPlanInput | None = None
report: InvestigationReportInput | None = None
@model_validator(mode="after")
def require_artifact(self):
if not self.evidence and self.plan is None and self.report is None:
raise ValueError("at least one artifact must be supplied")
return self
class TaskAccessRequestCreate(StrictModel):
request_id: UUID
source_session_id: NonEmptyStr
node_id: NonEmptyStr
scopes: Annotated[list[TaskDataSource], Field(min_length=1, max_length=7)]
reason: NonEmptyStr
class TaskAccessRequestDecision(StrictModel):
request_id: UUID
expected_version: PositiveInt
decision: Literal["approved", "rejected"]
approved_scopes: list[TaskDataSource] = Field(default_factory=list)
comment: NonEmptyStr | None = None
class TaskAccessRequest(StrictModel):
access_request_id: UUID
task_id: UUID
node_id: NonEmptyStr
scopes: list[TaskDataSource]
reason: NonEmptyStr
status: Literal["pending", "approved", "rejected"]
approved_scopes: list[TaskDataSource]
version: PositiveInt
requested_by: NonEmptyStr
requested_at: WorkbenchDateTime
decided_by: NonEmptyStr | None = None
decided_at: WorkbenchDateTime | None = None
comment: NonEmptyStr | None = None
class CreateWorkOrderPayload(StrictModel):
title: NonEmptyStr
description: NonEmptyStr
work_order_type: NonEmptyStr
priority: TaskPriority = "normal"
scheduled_at: WorkbenchDateTime | None = None
location: NonEmptyStr | None = None
assignee: NonEmptyStr | None = None
class CommandTarget(StrictModel):
kind: NonEmptyStr
id: NonEmptyStr
class IssueCommandPayload(StrictModel):
command_type: NonEmptyStr
target: CommandTarget
parameters: dict[str, JsonValue]
impact_summary: NonEmptyStr
preconditions: list[NonEmptyStr] = Field(min_length=1)
expires_at: WorkbenchDateTime
ActionPayload = CreateWorkOrderPayload | IssueCommandPayload
class ActionProposalCommand(StrictModel):
request_id: UUID
kind: ActionKind
reason: NonEmptyStr
payload: ActionPayload
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
node_id: NonEmptyStr | None = None
@model_validator(mode="after")
def validate_payload_kind(self):
expected = CreateWorkOrderPayload if self.kind == "create_work_order" else IssueCommandPayload
if not isinstance(self.payload, expected):
raise ValueError("action payload does not match its kind")
return self
class ActionDecisionCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
decision: Literal["approved", "rejected"]
comment: NonEmptyStr | None = None
class WorkOrderDraftUpdateCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
payload: CreateWorkOrderPayload
class WorkOrderConfirmDispatchCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class ActionExecuteCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class ActionReconcileCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class ActionReceipt(StrictModel):
adapter: NonEmptyStr
receipt_id: NonEmptyStr
submitted_at: WorkbenchDateTime
completed_at: WorkbenchDateTime | None = None
external_status: NonEmptyStr
detail: NonEmptyStr
class HumanApprovalAction(StrictModel):
action_id: UUID
task_id: UUID
version: PositiveInt
kind: ActionKind
reason: NonEmptyStr
payload: ActionPayload
evidence_refs: list[NonEmptyStr]
node_id: NonEmptyStr | None = None
required_approval_permission: NonEmptyStr
required_execution_permission: NonEmptyStr
approval_status: ApprovalStatus
execution_status: ExecutionStatus
execution_request_id: UUID | None = None
proposed_by: NonEmptyStr
proposed_at: WorkbenchDateTime
edited_by: NonEmptyStr | None = None
edited_at: WorkbenchDateTime | None = None
decided_by: NonEmptyStr | None = None
decided_at: WorkbenchDateTime | None = None
decision_comment: NonEmptyStr | None = None
executed_by: NonEmptyStr | None = None
executed_at: WorkbenchDateTime | None = None
receipt: ActionReceipt | None = None
execution_detail: NonEmptyStr | None = None
class ExternalWorkOrderSnapshot(StrictModel):
work_order_id: UUID
revision: PositiveInt
title: NonEmptyStr
summary: NonEmptyStr
status: Literal[
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
]
priority: Literal["low", "normal", "high", "urgent"]
assignee: NonEmptyStr | None = None
scheduled_at: WorkbenchDateTime | None = None
work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general"
location: NonEmptyStr | None = None
scenario_code: NonEmptyStr | None = None
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
activity: list[WorkOrderActivity] = Field(default_factory=list)
origin: Literal["synthetic"]
updated_at: WorkbenchDateTime
@model_validator(mode="before")
@classmethod
def accept_legacy_snapshot(cls, value):
if isinstance(value, dict):
return {key: item for key, item in value.items() if key != "subtasks"}
return value
class OperationalTaskRun(StrictModel):
run_id: UUID
task_id: UUID
generation: PositiveInt
execution_request_id: UUID
status: OperationalTaskRunStatus
revision: PositiveInt
agent_session_id: NonEmptyStr | None = None
lease_expires_at: WorkbenchDateTime | None = None
heartbeat_at: WorkbenchDateTime | None = None
started_at: WorkbenchDateTime | None = None
finished_at: WorkbenchDateTime | None = None
failure_code: NonEmptyStr | None = None
failure_detail: NonEmptyStr | None = None
dispatch_attempts: int = Field(default=0, ge=0)
next_dispatch_at: WorkbenchDateTime
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
class OperationalTaskSummary(StrictModel):
project_id: UUID
task_id: UUID
task_type: TaskType
workflow: NonEmptyStr
lifecycle_status: TaskLifecycleStatus = "open"
active_run: OperationalTaskRun | None = None
# Compatibility fields for the staged 014 -> 015 rollout. New consumers
# must use lifecycle_status and active_run.
status: TaskStatus
priority: TaskPriority
revision: PositiveInt
execution_generation: PositiveInt
execution_request_id: UUID
title: NonEmptyStr
summary: NonEmptyStr
source: TaskSource
trigger: TaskTrigger
scheduled_for: WorkbenchDateTime | None
agent_session_id: NonEmptyStr | None
agent_lease_expires_at: WorkbenchDateTime | None
current_plan_revision: PositiveInt | None
current_report_revision: PositiveInt | None
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
class OperationalTaskPermissions(StrictModel):
can_publish_artifacts: bool
can_propose_actions: bool
can_sync_external_feedback: bool
can_decide_work_orders: bool
can_execute_work_orders: bool
can_decide_commands: bool
can_execute_commands: bool
can_decide_access: bool = False
class OperationalTaskDetail(OperationalTaskSummary):
latest_plan: ExecutionPlanSnapshot | None
latest_report: InvestigationReportRevision | None
evidence: list[EvidenceRecord]
actions: list[HumanApprovalAction]
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
permissions: OperationalTaskPermissions
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
class OperationalTaskPage(StrictModel):
items: list[OperationalTaskSummary]
limit: PositiveInt
offset: int = Field(ge=0)
next_offset: int | None = Field(default=None, ge=0)
class OperationalTaskRunPage(StrictModel):
items: list[OperationalTaskRun]
next_cursor: NonEmptyStr | None = None
class AgentTaskContext(StrictModel):
task: OperationalTaskDetail
approved_data_scopes: list[NonEmptyStr]
selected_report: InvestigationReportRevision | None = None
class AgentRunCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
source_session_id: NonEmptyStr
class AgentRunFailureCommand(AgentRunCommand):
failure_code: NonEmptyStr
failure_detail: NonEmptyStr
class AgentRunCancelCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
reason: NonEmptyStr
class AgentRunDispatchFailureCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
failure_code: NonEmptyStr
failure_detail: NonEmptyStr
retryable: bool = True
class OperationalTaskRetryCommand(StrictModel):
request_id: UUID
reason: NonEmptyStr
class RecoverExpiredLeasesCommand(StrictModel):
limit: int = Field(default=20, ge=1, le=100)
class RecoverExpiredLeasesResult(StrictModel):
task_ids: list[UUID]
recovered_at: WorkbenchDateTime
class AgentArtifactsResult(StrictModel):
task: OperationalTaskDetail
plan_revision: PositiveInt | None
report_revision: PositiveInt | None
class TaskDataQueryResult(StrictModel):
evidence: EvidenceRecord
snapshot: TaskDataSnapshot
class ExternalFeedbackEvidence(StrictModel):
evidence_id: NonEmptyStr
kind: Literal["external_feedback", "synthetic"] = "external_feedback"
summary: NonEmptyStr
source_label: NonEmptyStr
source_ref: NonEmptyStr | None = None
observed_at: WorkbenchDateTime
class ExternalFeedbackCommand(StrictModel):
request_id: UUID
source_event_id: NonEmptyStr
source_revision: PositiveInt
status: Literal[
"scheduled", "assigned", "in_progress", "awaiting_feedback",
"completed", "cancelled", "failed"
]
occurred_at: WorkbenchDateTime
detail: NonEmptyStr
evidence: ExternalFeedbackEvidence | None = None
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
@model_validator(mode="after")
def snapshot_matches_feedback_revision(self):
if self.work_order_snapshot is not None:
work_order_id = str(self.work_order_snapshot.work_order_id)
if self.source_event_id not in {
work_order_id, f"peripheral_sample:{work_order_id}"
}:
raise ValueError("work_order_snapshot id must match source_event_id")
if self.work_order_snapshot.revision != self.source_revision:
raise ValueError("work_order_snapshot revision must match source_revision")
expected_status = {
"draft": "scheduled",
"open": "assigned",
"in_progress": "in_progress",
"awaiting_reply": "awaiting_feedback",
"completed": "completed",
"cancelled": "cancelled",
}[self.work_order_snapshot.status]
if self.status != expected_status:
raise ValueError("work_order_snapshot status must match feedback status")
return self
class ExternalFeedbackResult(StrictModel):
task: OperationalTaskDetail
source_event_id: NonEmptyStr
source_revision: PositiveInt
accepted_at: WorkbenchDateTime
class ExternalSourceWithdrawalCommand(StrictModel):
request_id: UUID
source_event_id: NonEmptyStr
class HumanEvidenceCommand(StrictModel):
request_id: UUID
evidence: EvidenceInput
class OperationalTaskNotification(StrictModel):
notification_id: UUID
task_id: UUID
kind: Literal[
"task_created", "action_pending", "report_updated", "task_completed",
"task_failed", "run_delivery_degraded", "access_approval_pending",
"action_approval_pending", "human_input_required",
"external_coordination_failed"
]
title: NonEmptyStr
description: NonEmptyStr
required_permission: NonEmptyStr | None = None
lifecycle_status: Literal["active", "resolved"] = "active"
subject_type: Literal["task", "run", "access_request", "action"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
resolved_at: WorkbenchDateTime | None = None
resolution_reason: NonEmptyStr | None = None
status: Literal["unread", "read"]
version: PositiveInt
created_at: WorkbenchDateTime
read_at: WorkbenchDateTime | None = None
read_by: NonEmptyStr | None = None
class OperationalTaskNotificationPage(StrictModel):
items: list[OperationalTaskNotification]
limit: PositiveInt
offset: int = Field(ge=0)
next_offset: int | None = Field(default=None, ge=0)
class NotificationAcknowledgeCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class OperationalTaskScheduleCreate(StrictModel):
request_id: UUID
schedule_key: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
interval_seconds: int = Field(ge=60, le=31_536_000)
next_run_at: WorkbenchDateTime
priority: TaskPriority = "normal"
class OperationalTaskSchedule(StrictModel):
schedule_id: UUID
schedule_key: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
interval_seconds: int = Field(ge=60, le=31_536_000)
next_run_at: WorkbenchDateTime
priority: TaskPriority
enabled: bool
version: PositiveInt
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
class OperationalTaskSchedulePage(StrictModel):
items: list[OperationalTaskSchedule]
class OperationalTaskScheduleUpdate(StrictModel):
request_id: UUID
expected_version: PositiveInt
enabled: bool
class DispatchDueSchedulesCommand(StrictModel):
limit: int = Field(default=20, ge=1, le=100)
class DispatchDueSchedulesResult(StrictModel):
task_ids: list[UUID]
dispatched_at: WorkbenchDateTime