Persist a required Server-owned execution scope and enforce it for listing, claiming, lease recovery and SCADA verification. Backfill existing tasks explicitly through migration 034 so local development workers no longer claim production tasks from the shared database.
1027 lines
36 KiB
Python
1027 lines
36 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_serializer, model_validator
|
|
from app.domain.schemas.answer_ui import (
|
|
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, 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 ScadaReplayTaskContext(StrictModel):
|
|
replay_id: UUID
|
|
knowledge_cutoff: WorkbenchDateTime
|
|
batch_sequence: PositiveInt
|
|
|
|
|
|
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
|
|
replay_context: ScadaReplayTaskContext | 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"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
|
|
diagram.mermaid,
|
|
re.MULTILINE,
|
|
):
|
|
missing.append(node.node_id)
|
|
return missing
|
|
|
|
|
|
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
|
|
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_result(self):
|
|
validate_report_ui_blocks(self.ui_blocks, self.evidence_refs)
|
|
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 InvestigationProgress(StrictModel):
|
|
"""A complete reading snapshot, not a plan or a business result."""
|
|
|
|
summary: Annotated[str, Field(min_length=1, max_length=20000)]
|
|
diagram: InvestigationDiagram | None = None
|
|
|
|
|
|
class InvestigationProgressSnapshot(StrictModel):
|
|
revision: PositiveInt
|
|
run_id: UUID
|
|
execution_generation: PositiveInt
|
|
published_at: WorkbenchDateTime
|
|
source_session_id: NonEmptyStr
|
|
progress: InvestigationProgress
|
|
|
|
|
|
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
|
|
progress: InvestigationProgress | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_artifacts(self):
|
|
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
|
|
raise ValueError("at least one artifact must be supplied")
|
|
for diagram in (
|
|
self.report.diagram if self.report is not None else None,
|
|
self.progress.diagram if self.progress is not None else None,
|
|
):
|
|
missing = _undeclared_diagram_node_refs(diagram)
|
|
if missing:
|
|
raise ValueError(
|
|
"diagram node references must use ids declared in Mermaid source: "
|
|
+ ", ".join(missing)
|
|
)
|
|
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["system_data"]
|
|
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
|
|
agent_instance_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):
|
|
execution_scope: NonEmptyStr
|
|
project_id: UUID
|
|
task_id: UUID
|
|
task_type: TaskType
|
|
workflow: NonEmptyStr
|
|
lifecycle_status: TaskLifecycleStatus = "open"
|
|
active_run: OperationalTaskRun | None = None
|
|
latest_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
|
|
handling_context: dict[str, JsonValue] = Field(default_factory=dict)
|
|
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)]
|
|
type: Literal["line", "bar", "scatter"] | None = None
|
|
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)] | None], Field(min_length=1, max_length=100)]
|
|
sample_times: Annotated[list[Annotated[str, Field(
|
|
strict=True, max_length=64,
|
|
pattern=CHART_SAMPLE_TIME_PATTERN,
|
|
)] | None], Field(min_length=1, max_length=100)] | None = None
|
|
|
|
@model_serializer(mode="wrap")
|
|
def serialize_optional_fields(self, handler):
|
|
return {key: value for key, value in handler(self).items() if value is not None}
|
|
|
|
@model_validator(mode="after")
|
|
def validate_sample_times(self):
|
|
if self.sample_times is not None:
|
|
if len(self.sample_times) != len(self.data):
|
|
raise ValueError("chart sample_times must align with data")
|
|
for value, at in zip(self.data, self.sample_times, strict=True):
|
|
if at is not None:
|
|
if value is None:
|
|
raise ValueError("chart sample_times require an observed value")
|
|
validate_chart_sample_time(at)
|
|
return self
|
|
|
|
|
|
class TaskSafeChart(StrictModel):
|
|
title: Annotated[str, Field(max_length=200)]
|
|
chart_type: Literal["line", "bar"]
|
|
x_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
|
y_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
|
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_serializer(mode="wrap")
|
|
def serialize_optional_fields(self, handler):
|
|
return {key: value for key, value in handler(self).items() if value is not None}
|
|
|
|
@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
|