"""Contracts for project-scoped operational tasks and human-approved actions.""" from __future__ import annotations from typing import Annotated, Literal from uuid import UUID from pydantic import Field, JsonValue, model_validator from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel from app.domain.schemas.operational_data import TaskDataSnapshot 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 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) class ExecutionPlanEdge(StrictModel): 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"] = 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() 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) 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") 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 InvestigationReportInput(StrictModel): 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) 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: InvestigationReportInput 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 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 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 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 class OperationalTaskDetail(OperationalTaskSummary): latest_plan: ExecutionPlanSnapshot | None latest_report: InvestigationReportRevision | None evidence: list[EvidenceRecord] actions: list[HumanApprovalAction] 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] 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 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" ] title: NonEmptyStr description: NonEmptyStr required_permission: 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