"""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