"""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.answer_ui import ReportUiBlock, validate_report_ui_blocks from app.domain.schemas.task_question import TaskQuestionRequest 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 TaskMapLayer = Literal["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"] TaskFeatureId = Annotated[str, Field(min_length=1, max_length=128)] class InvestigationMapTarget(StrictModel): source_id: TaskMapLayer feature_id: TaskFeatureId 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) status: Literal[ "pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled" ] | None = Field(default=None, description=( "Evidence-confirmed node state; waiting means prerequisites/external/system result, " "waiting_human requires manual authorization/approval/feedback. " "Unknown stays omitted/null; host controls colors." )) map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100) 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 def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[str]: if diagram is None: return [] missing: list[str] = [] for node in diagram.node_refs: escaped = re.escape(node.node_id) if not re.search( rf"(? 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 can_answer_questions: bool = False class OperationalTaskDetail(OperationalTaskSummary): latest_plan: ExecutionPlanSnapshot | None latest_report: InvestigationReportRevision | None latest_progress: InvestigationProgressSnapshot | None = None evidence: list[EvidenceRecord] actions: list[HumanApprovalAction] access_requests: list[TaskAccessRequest] = Field(default_factory=list) questions: list[TaskQuestionRequest] = 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 agent_instance_id: NonEmptyStr | None = None 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) task_type: TaskType | None = None task_created_from: WorkbenchDateTime | None = None class RecoverExpiredLeasesResult(StrictModel): task_ids: list[UUID] recovered_at: WorkbenchDateTime class AgentArtifactsResult(StrictModel): task: OperationalTaskDetail plan_revision: PositiveInt | None report_revision: PositiveInt | None progress_revision: PositiveInt | None = None class TaskSafeChartSeries(StrictModel): name: Annotated[str, Field(max_length=128)] data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)]], Field(min_length=1, max_length=100)] class TaskSafeChart(StrictModel): title: Annotated[str, Field(max_length=200)] chart_type: Literal["line", "bar"] x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)] series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)] @model_validator(mode="after") def validate_alignment(self): if any(len(series.data) != len(self.x_data) for series in self.series): raise ValueError("chart series must align with x_data") return self class TaskHighlightFeatures(StrictModel): action: Literal["highlight_features"] layer: TaskMapLayer feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)] effect: Literal["steady", "pulse"] class TaskLocateFeatures(StrictModel): action: Literal["locate_features"] layer: TaskMapLayer feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)] class TaskZoomToMap(StrictModel): action: Literal["zoom_to_map"] center: tuple[ Annotated[float, Field(strict=True, ge=-180, le=180, allow_inf_nan=False)], Annotated[float, Field(strict=True, ge=-90, le=90, allow_inf_nan=False)], ] zoom: Annotated[float, Field(strict=True, ge=0, le=24, allow_inf_nan=False)] class TaskScadaAnalysisItem(StrictModel): device_id: TaskFeatureId level: Literal["high", "medium", "low", "unrated"] class TaskRenderScadaAnalysis(StrictModel): action: Literal["render_scada_analysis"] items: Annotated[list[TaskScadaAnalysisItem], Field(min_length=1, max_length=100)] @model_validator(mode="after") def validate_unique_devices(self): if len({item.device_id for item in self.items}) != len(self.items): raise ValueError("SCADA analysis device IDs must be unique") return self class TaskClearScadaAnalysis(StrictModel): action: Literal["clear_scada_analysis"] class TaskOverlayGeometry(StrictModel): type: Literal["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"] coordinates: list[JsonValue] class TaskOverlayFeature(StrictModel): type: Literal["Feature"] geometry: TaskOverlayGeometry properties: dict[str, JsonValue] | None id: Annotated[str, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)] | None = None class TaskOverlayData(StrictModel): type: Literal["FeatureCollection"] features: Annotated[list[TaskOverlayFeature], Field(min_length=1, max_length=1000)] class TaskClearHighlights(StrictModel): action: Literal["clear_highlights"] class TaskOpenChart(StrictModel): action: Literal["open_chart"] chart_id: TaskFeatureId chart: TaskSafeChart evidence_refs: Annotated[list[NonEmptyStr], Field(min_length=1, max_length=100)] class TaskCloseChart(StrictModel): action: Literal["close_chart"] chart_id: TaskFeatureId class TaskSetOverlay(StrictModel): action: Literal["set_overlay"] overlay_id: TaskFeatureId title: Annotated[str, Field(min_length=1, max_length=200)] mode: Literal["geometry", "heatmap"] evidence_id: NonEmptyStr data: TaskOverlayData | None = None class TaskClearOverlay(StrictModel): action: Literal["clear_overlay"] overlay_id: TaskFeatureId TaskPresentationCommand = Annotated[ TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis | TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay, Field(discriminator="action"), ] class TaskPresentationEventCommand(StrictModel): request_id: UUID run_id: UUID source_session_id: NonEmptyStr command: TaskPresentationCommand class TaskPresentationEventResult(StrictModel): recorded: Literal[True] = True event_seq: PositiveInt run_id: UUID execution_generation: PositiveInt class TaskPresentationActiveRun(StrictModel): run_id: UUID generation: PositiveInt status: OperationalTaskRunStatus class TaskPresentationCursor(StrictModel): task_id: UUID execution_generation: PositiveInt active_run: TaskPresentationActiveRun | None event_cursor: int = Field(ge=0) class TaskPresentationHistoryItem(StrictModel): event_seq: PositiveInt run_id: UUID execution_generation: PositiveInt occurred_at: WorkbenchDateTime # Stored records are validated against the current command contract by the # reader; an obsolete record must not prevent reading the rest of the page. command: dict[str, JsonValue] class TaskPresentationHistoryPage(StrictModel): task_id: UUID event_cursor: int = Field(ge=0) items: list[TaskPresentationHistoryItem] next_after: int | None = Field(default=None, ge=0) 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", "question"] = "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