feat(workbench): add dynamic task approval lifecycle
This commit is contained in:
@@ -7,7 +7,7 @@ from uuid import UUID
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
from app.domain.schemas.workbench_runtime import (
|
||||
WorkbenchDateTime,
|
||||
WorkOrderActivity,
|
||||
@@ -43,6 +43,7 @@ class TaskSource(StrictModel):
|
||||
external_id: NonEmptyStr | None = None
|
||||
external_revision: PositiveInt | None = None
|
||||
event_kind: NonEmptyStr | None = None
|
||||
deleted_at: WorkbenchDateTime | None = None
|
||||
|
||||
|
||||
class TaskTrigger(StrictModel):
|
||||
@@ -72,16 +73,21 @@ class ExecutionPlanNode(StrictModel):
|
||||
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"] = Field(alias="schema")
|
||||
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)]
|
||||
@@ -98,6 +104,7 @@ class ExecutionPlanInput(StrictModel):
|
||||
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:
|
||||
@@ -105,6 +112,10 @@ class ExecutionPlanInput(StrictModel):
|
||||
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]
|
||||
@@ -118,6 +129,45 @@ class ExecutionPlanInput(StrictModel):
|
||||
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
|
||||
|
||||
|
||||
@@ -182,6 +232,38 @@ class AgentArtifactsCommand(StrictModel):
|
||||
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
|
||||
@@ -232,6 +314,17 @@ class ActionDecisionCommand(StrictModel):
|
||||
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
|
||||
@@ -267,6 +360,8 @@ class HumanApprovalAction(StrictModel):
|
||||
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
|
||||
@@ -358,6 +453,7 @@ class OperationalTaskPermissions(StrictModel):
|
||||
can_execute_work_orders: bool
|
||||
can_decide_commands: bool
|
||||
can_execute_commands: bool
|
||||
can_decide_access: bool = False
|
||||
|
||||
|
||||
class OperationalTaskDetail(OperationalTaskSummary):
|
||||
@@ -365,6 +461,7 @@ class OperationalTaskDetail(OperationalTaskSummary):
|
||||
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
|
||||
|
||||
@@ -488,6 +585,11 @@ class ExternalFeedbackResult(StrictModel):
|
||||
accepted_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class ExternalSourceWithdrawalCommand(StrictModel):
|
||||
request_id: UUID
|
||||
source_event_id: NonEmptyStr
|
||||
|
||||
|
||||
class HumanEvidenceCommand(StrictModel):
|
||||
request_id: UUID
|
||||
evidence: EvidenceInput
|
||||
@@ -505,6 +607,11 @@ class OperationalTaskNotification(StrictModel):
|
||||
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
|
||||
resolved_at: WorkbenchDateTime | None = None
|
||||
resolution_reason: NonEmptyStr | None = None
|
||||
status: Literal["unread", "read"]
|
||||
version: PositiveInt
|
||||
created_at: WorkbenchDateTime
|
||||
|
||||
Reference in New Issue
Block a user