884 lines
30 KiB
Python
884 lines
30 KiB
Python
"""Contracts for project-scoped operational tasks and human-approved actions."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Annotated, Literal
|
|
from uuid import UUID
|
|
|
|
from pydantic import Field, JsonValue, model_validator
|
|
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
|
|
|
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_human", "failed", "skipped", "cancelled"
|
|
] | None = None
|
|
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
|
|
|
|
|
|
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 require_artifact(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")
|
|
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
|
|
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
|
|
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
|
|
latest_progress: InvestigationProgressSnapshot | None = 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)
|
|
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"] = "steady"
|
|
|
|
|
|
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
|
|
|
|
|
|
class TaskClearOverlay(StrictModel):
|
|
action: Literal["clear_overlay"]
|
|
overlay_id: TaskFeatureId
|
|
|
|
|
|
TaskPresentationCommand = Annotated[
|
|
TaskHighlightFeatures | 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 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
|