feat(workbench): add dynamic task approval lifecycle

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent db092b0f5e
commit ad813a7c16
35 changed files with 2956 additions and 564 deletions
+109 -2
View File
@@ -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