feat(workbench): add dynamic task approval lifecycle
This commit is contained in:
@@ -13,6 +13,8 @@ from app.auth.permissions import (
|
||||
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||
OPERATIONAL_TASK_VIEW,
|
||||
OPERATIONAL_TASK_WRITE,
|
||||
SCADA_VIEW,
|
||||
SIMULATION_VIEW,
|
||||
permissions_for_context,
|
||||
require_permission,
|
||||
)
|
||||
@@ -28,6 +30,11 @@ from app.domain.schemas.operational_task import (
|
||||
ActionExecuteCommand,
|
||||
ActionProposalCommand,
|
||||
ActionReconcileCommand,
|
||||
WorkOrderDraftUpdateCommand,
|
||||
WorkOrderConfirmDispatchCommand,
|
||||
TaskAccessRequest,
|
||||
TaskAccessRequestCreate,
|
||||
TaskAccessRequestDecision,
|
||||
AgentArtifactsCommand,
|
||||
AgentArtifactsResult,
|
||||
AgentRunCancelCommand,
|
||||
@@ -40,6 +47,7 @@ from app.domain.schemas.operational_task import (
|
||||
EvidenceRecord,
|
||||
ExternalFeedbackCommand,
|
||||
ExternalFeedbackResult,
|
||||
ExternalSourceWithdrawalCommand,
|
||||
HumanApprovalAction,
|
||||
HumanEvidenceCommand,
|
||||
NotificationAcknowledgeCommand,
|
||||
@@ -77,10 +85,17 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
|
||||
return OperationalTaskService(
|
||||
OperationalTaskRepository(conn, ctx.project_id),
|
||||
ctx,
|
||||
permissions_for_context(ctx),
|
||||
_task_permissions(ctx),
|
||||
)
|
||||
|
||||
|
||||
def _task_permissions(ctx: ProjectContext) -> set[str]:
|
||||
permissions = set(permissions_for_context(ctx))
|
||||
if ctx.system_role == "service":
|
||||
permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW})
|
||||
return permissions
|
||||
|
||||
|
||||
def _raise_domain(exc: DomainError):
|
||||
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
|
||||
|
||||
@@ -434,9 +449,12 @@ async def query_operational_task_data(
|
||||
):
|
||||
service = _service(ctx, conn)
|
||||
try:
|
||||
await service.detail(task_id)
|
||||
if not await service.can_read_source(task_id, query.source):
|
||||
raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403)
|
||||
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
||||
snapshot = await read_task_data(
|
||||
context=ctx, query=query, credential=authorization
|
||||
context=ctx, query=query, credential=authorization,
|
||||
delegated=source_permission not in _task_permissions(ctx),
|
||||
)
|
||||
evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot)
|
||||
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
|
||||
@@ -446,6 +464,40 @@ async def query_operational_task_data(
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/{task_id}/access-requests",
|
||||
response_model=TaskAccessRequest,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def request_operational_task_access(
|
||||
task_id: UUID,
|
||||
command: TaskAccessRequestCreate,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).request_access(task_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/{task_id}/access-requests/{access_request_id}/decision",
|
||||
response_model=TaskAccessRequest,
|
||||
)
|
||||
async def decide_operational_task_access(
|
||||
task_id: UUID,
|
||||
access_request_id: UUID,
|
||||
command: TaskAccessRequestDecision,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).decide_access(task_id, access_request_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/{task_id}/human-evidence",
|
||||
response_model=OperationalTaskDetail,
|
||||
@@ -512,6 +564,44 @@ async def decide_operational_task_action(
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/operational-tasks/{task_id}/actions/{action_id}/draft",
|
||||
response_model=HumanApprovalAction,
|
||||
)
|
||||
async def update_operational_work_order_draft(
|
||||
task_id: UUID,
|
||||
action_id: UUID,
|
||||
command: WorkOrderDraftUpdateCommand,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).update_work_order_draft(task_id, action_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch",
|
||||
response_model=HumanApprovalAction,
|
||||
)
|
||||
async def confirm_operational_work_order_dispatch(
|
||||
task_id: UUID,
|
||||
action_id: UUID,
|
||||
command: WorkOrderConfirmDispatchCommand,
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).confirm_work_order_dispatch(
|
||||
task_id, action_id, command, dispatcher, authorization
|
||||
)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/{task_id}/actions/{action_id}/execute",
|
||||
response_model=HumanApprovalAction,
|
||||
@@ -568,3 +658,19 @@ async def synchronize_operational_task_feedback(
|
||||
return await _service(ctx, conn).external_feedback(task_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operational-tasks/{task_id}/withdraw-external-source",
|
||||
response_model=OperationalTaskDetail,
|
||||
)
|
||||
async def withdraw_operational_task_external_source(
|
||||
task_id: UUID,
|
||||
command: ExternalSourceWithdrawalCommand,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).withdraw_external_source(task_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
@@ -18,7 +18,6 @@ from app.domain.schemas.workbench_runtime import (
|
||||
WorkbenchPersistedAlert,
|
||||
WorkbenchRuntimeLatestAnalysis,
|
||||
WorkbenchRuntimeSnapshot,
|
||||
WorkbenchRuntimeWorkOrder,
|
||||
)
|
||||
from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
@@ -134,12 +133,9 @@ async def workbench_runtime(
|
||||
latest_results: list[dict] | None = None
|
||||
latest_analysis_map_payload: dict | None = None
|
||||
analysis_availability = "not_configured"
|
||||
work_order_storage_configured = False
|
||||
work_orders: list[dict] | None = None
|
||||
alert_storage_configured = False
|
||||
alerts: list[dict] | None = None
|
||||
schedule_availability = "not_configured"
|
||||
work_order_availability = "not_configured"
|
||||
alert_availability = "not_configured"
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
@@ -168,29 +164,11 @@ async def workbench_runtime(
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
operations_availability = await WorkbenchOperationsRepository.availability(conn)
|
||||
work_order_storage_configured = operations_availability.work_orders
|
||||
alert_storage_configured = operations_availability.alerts
|
||||
except Exception:
|
||||
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
|
||||
schedule_availability = "degraded"
|
||||
work_order_availability = "degraded"
|
||||
alert_availability = "degraded"
|
||||
if work_order_storage_configured:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
work_order_documents = await WorkbenchOperationsRepository.list_work_orders(
|
||||
conn, str(ctx.project_id)
|
||||
)
|
||||
work_orders, invalid_work_orders = _validate_documents(
|
||||
work_order_documents, WorkbenchRuntimeWorkOrder, "work order"
|
||||
)
|
||||
work_order_availability = (
|
||||
"degraded" if invalid_work_orders else "ready"
|
||||
)
|
||||
except Exception:
|
||||
work_orders = None
|
||||
work_order_availability = "degraded"
|
||||
logger.warning("Workbench work-order projection is unavailable", exc_info=True)
|
||||
if alert_storage_configured:
|
||||
try:
|
||||
async with _projection_transaction(conn):
|
||||
@@ -209,14 +187,11 @@ async def workbench_runtime(
|
||||
analysis_runs=analysis_runs,
|
||||
latest_analysis_results=latest_results,
|
||||
latest_analysis_map_payload=latest_analysis_map_payload,
|
||||
work_order_storage_configured=work_order_storage_configured,
|
||||
work_orders=work_orders,
|
||||
alert_storage_configured=alert_storage_configured,
|
||||
persisted_alerts=alerts,
|
||||
scenario_availability=analysis_availability,
|
||||
artifact_availability=analysis_availability,
|
||||
schedule_availability=schedule_availability,
|
||||
work_order_availability=work_order_availability,
|
||||
alert_availability=alert_availability,
|
||||
projection_degraded=False,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ OPERATIONAL_TASK_VIEW = "operational_task.view"
|
||||
OPERATIONAL_TASK_WRITE = "operational_task.write"
|
||||
OPERATIONAL_TASK_EXECUTE = "operational_task.execute"
|
||||
OPERATIONAL_TASK_EXTERNAL_SYNC = "operational_task.external_sync"
|
||||
OPERATIONAL_TASK_ACCESS_APPROVE = "operational_task.access.approve"
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE = "operational_action.work_order.approve"
|
||||
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE = "operational_action.work_order.execute"
|
||||
OPERATIONAL_ACTION_COMMAND_APPROVE = "operational_action.command.approve"
|
||||
@@ -46,6 +47,7 @@ PROJECT_MEMBER_PERMISSIONS = frozenset(
|
||||
OPTIMIZATION_RUN,
|
||||
OPERATIONAL_TASK_VIEW,
|
||||
OPERATIONAL_TASK_WRITE,
|
||||
OPERATIONAL_TASK_ACCESS_APPROVE,
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
||||
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -252,6 +252,7 @@ class OperationalTaskRepository:
|
||||
ORDER BY run.generation DESC LIMIT 1
|
||||
) AS active_run ON true
|
||||
WHERE task.project_id=%s
|
||||
AND (task.document#>>'{source,deleted_at}') IS NULL
|
||||
AND (%s::text IS NULL OR task.task_type=%s::text)
|
||||
AND (%s::text IS NULL OR task.status=%s::text)
|
||||
ORDER BY task.updated_at DESC,task.task_id
|
||||
@@ -336,6 +337,55 @@ class OperationalTaskRepository:
|
||||
evidence["recorded_at"]),
|
||||
)
|
||||
|
||||
async def access_requests_available(self) -> bool:
|
||||
# Older project databases can serve existing tasks before migration 019.
|
||||
# Keep approval writes fail-closed until the table is provisioned.
|
||||
availability = await self.connection.execute(
|
||||
"SELECT to_regclass('workbench.operational_task_access_requests') AS relation"
|
||||
)
|
||||
return (await availability.fetchone())["relation"] is not None
|
||||
|
||||
async def list_access_requests(self, task_id: str) -> list[dict]:
|
||||
if not await self.access_requests_available():
|
||||
return []
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_access_requests
|
||||
WHERE project_id=%s AND task_id=%s
|
||||
ORDER BY requested_at,access_request_id""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def get_access_request(self, task_id: str, access_request_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_access_requests
|
||||
WHERE project_id=%s AND task_id=%s AND access_request_id=%s""",
|
||||
(self.project_id, task_id, access_request_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def insert_access_request(self, task_id: str, document: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_access_requests
|
||||
(project_id,task_id,access_request_id,node_id,status,version,document,requested_at,updated_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(self.project_id, task_id, document["access_request_id"], document["node_id"],
|
||||
document["status"], document["version"], Jsonb(document),
|
||||
document["requested_at"], document["requested_at"]),
|
||||
)
|
||||
|
||||
async def update_access_request(self, task_id: str, document: dict, expected_version: int) -> None:
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.operational_task_access_requests
|
||||
SET status=%s,version=%s,document=%s,updated_at=%s
|
||||
WHERE project_id=%s AND task_id=%s AND access_request_id=%s AND version=%s""",
|
||||
(document["status"], document["version"], Jsonb(document), document["decided_at"],
|
||||
self.project_id, task_id, document["access_request_id"], expected_version),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise DomainError("VERSION_CONFLICT", "Access request version changed", status=409)
|
||||
|
||||
async def get_latest_plan(self, task_id: str) -> dict | None:
|
||||
return await self._latest_artifact("operational_task_plans", task_id)
|
||||
|
||||
@@ -406,7 +456,7 @@ class OperationalTaskRepository:
|
||||
version=%s,approval_status=%s,execution_status=%s,document=%s,updated_at=%s
|
||||
WHERE project_id=%s AND task_id=%s AND action_id=%s""",
|
||||
(action["version"], action["approval_status"], action["execution_status"],
|
||||
Jsonb(action), action.get("executed_at") or action.get("decided_at") or
|
||||
Jsonb(action), action.get("executed_at") or action.get("decided_at") or action.get("edited_at") or
|
||||
action["proposed_at"], self.project_id, task_id, action["action_id"]),
|
||||
)
|
||||
|
||||
@@ -417,7 +467,7 @@ class OperationalTaskRepository:
|
||||
VALUES (%s,%s,%s,%s,%s,%s)""",
|
||||
(
|
||||
self.project_id, task_id, action["action_id"], action["version"],
|
||||
Jsonb(action), action.get("executed_at") or action.get("decided_at")
|
||||
Jsonb(action), action.get("executed_at") or action.get("decided_at") or action.get("edited_at")
|
||||
or action["proposed_at"],
|
||||
),
|
||||
)
|
||||
@@ -453,6 +503,41 @@ class OperationalTaskRepository:
|
||||
notification["created_at"], Jsonb(notification)),
|
||||
)
|
||||
|
||||
async def resolve_notifications(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
kinds: list[str],
|
||||
subject_type: str,
|
||||
subject_id: str,
|
||||
resolved_at,
|
||||
resolution_reason: str,
|
||||
) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.operational_task_notifications
|
||||
SET document=document || jsonb_build_object(
|
||||
'lifecycle_status','resolved',
|
||||
'resolved_at',to_jsonb(%s::timestamptz),
|
||||
'resolution_reason',%s
|
||||
)
|
||||
WHERE project_id=%s AND task_id=%s
|
||||
AND COALESCE(document->>'lifecycle_status','active')='active'
|
||||
AND document->>'kind'=ANY(%s)
|
||||
AND document->>'subject_type'=%s
|
||||
AND document->>'subject_id'=%s
|
||||
RETURNING document""",
|
||||
(
|
||||
resolved_at,
|
||||
resolution_reason,
|
||||
self.project_id,
|
||||
task_id,
|
||||
kinds,
|
||||
subject_type,
|
||||
subject_id,
|
||||
),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def get_notification(self, notification_id: str, user_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT n.document || jsonb_build_object(
|
||||
@@ -499,6 +584,7 @@ class OperationalTaskRepository:
|
||||
ON r.project_id=n.project_id AND r.notification_id=n.notification_id
|
||||
AND r.user_id=%s
|
||||
WHERE n.project_id=%s
|
||||
AND COALESCE(n.document->>'lifecycle_status','active')='active'
|
||||
AND (%s=false OR r.read_at IS NULL)
|
||||
AND (n.document->>'required_permission' IS NULL
|
||||
OR n.document->>'required_permission'=ANY(%s))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Optional project-local schedule, work-order and alert projections."""
|
||||
"""Optional project-local alert projection."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -8,7 +8,6 @@ from psycopg import AsyncConnection
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkbenchOperationsAvailability:
|
||||
work_orders: bool
|
||||
alerts: bool
|
||||
schedules: bool = False
|
||||
|
||||
@@ -23,34 +22,14 @@ class WorkbenchOperationsRepository:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
to_regclass('workbench.work_orders') IS NOT NULL AS work_orders,
|
||||
to_regclass('workbench.alerts') IS NOT NULL AS alerts
|
||||
SELECT to_regclass('workbench.alerts') IS NOT NULL AS alerts
|
||||
"""
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return WorkbenchOperationsAvailability(
|
||||
work_orders=bool(row and row["work_orders"]),
|
||||
alerts=bool(row and row["alerts"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def list_work_orders(
|
||||
conn: AsyncConnection, project_id: str, *, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT document
|
||||
FROM workbench.work_orders
|
||||
WHERE project_id = %s
|
||||
ORDER BY updated_at DESC, work_order_id
|
||||
LIMIT %s
|
||||
""",
|
||||
(project_id, limit),
|
||||
)
|
||||
return [row["document"] for row in await cur.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def list_alerts(
|
||||
conn: AsyncConnection, project_id: str, *, limit: int = 100
|
||||
|
||||
@@ -19,6 +19,7 @@ class TaskDataSettings(BaseSettings):
|
||||
server_url: str = "http://127.0.0.1:8000"
|
||||
peripheral_url: str = ""
|
||||
timeout_seconds: float = Field(default=15, ge=1, le=60)
|
||||
delegation_bearer: str = ""
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="OPERATIONAL_DATA_", extra="ignore",
|
||||
env_file=Path(__file__).resolve().parents[3] / ".env",
|
||||
@@ -81,13 +82,20 @@ def _select_demo_observations(
|
||||
|
||||
async def read_task_data(
|
||||
*, context: ProjectContext, query: TaskDataQuery, credential: str | None,
|
||||
delegated: bool = False,
|
||||
settings: TaskDataSettings | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> TaskDataSnapshot:
|
||||
config = settings or TaskDataSettings()
|
||||
permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
||||
if permission not in permissions_for_context(context):
|
||||
if permission not in permissions_for_context(context) and not delegated:
|
||||
raise TaskDataError(403, "当前身份没有该数据源的读取权限")
|
||||
if delegated:
|
||||
credential = config.delegation_bearer
|
||||
if not credential:
|
||||
raise TaskDataError(503, "任务授权已批准,但数据源委托身份尚未配置")
|
||||
if not credential.lower().startswith("bearer "):
|
||||
credential = f"Bearer {credential}"
|
||||
if not credential or not credential.lower().startswith("bearer "):
|
||||
raise TaskDataError(401, "数据查询需要当前产品身份")
|
||||
peripheral = query.source.startswith("peripheral_")
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.auth.permissions import (
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
||||
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
OPERATIONAL_TASK_EXECUTE,
|
||||
OPERATIONAL_TASK_ACCESS_APPROVE,
|
||||
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||
OPERATIONAL_TASK_WRITE,
|
||||
SCADA_VIEW,
|
||||
@@ -22,6 +23,11 @@ from app.domain.schemas.operational_task import (
|
||||
ActionExecuteCommand,
|
||||
ActionProposalCommand,
|
||||
ActionReconcileCommand,
|
||||
WorkOrderDraftUpdateCommand,
|
||||
WorkOrderConfirmDispatchCommand,
|
||||
TaskAccessRequest,
|
||||
TaskAccessRequestCreate,
|
||||
TaskAccessRequestDecision,
|
||||
AgentArtifactsCommand,
|
||||
AgentArtifactsResult,
|
||||
AgentTaskContext,
|
||||
@@ -35,6 +41,7 @@ from app.domain.schemas.operational_task import (
|
||||
ExecutionPlanSnapshot,
|
||||
ExternalFeedbackCommand,
|
||||
ExternalFeedbackResult,
|
||||
ExternalSourceWithdrawalCommand,
|
||||
HumanApprovalAction,
|
||||
HumanEvidenceCommand,
|
||||
InvestigationReportRevision,
|
||||
@@ -54,6 +61,7 @@ from app.domain.schemas.operational_task import (
|
||||
StoredEvidenceRecord,
|
||||
)
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.domain.schemas.operational_data import TaskDataSource
|
||||
from app.services.content_digest import digest
|
||||
|
||||
|
||||
@@ -124,6 +132,7 @@ class OperationalTaskService:
|
||||
can_execute_work_orders=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE in permissions,
|
||||
can_decide_commands=OPERATIONAL_ACTION_COMMAND_APPROVE in permissions,
|
||||
can_execute_commands=OPERATIONAL_ACTION_COMMAND_EXECUTE in permissions,
|
||||
can_decide_access=OPERATIONAL_TASK_ACCESS_APPROVE in permissions,
|
||||
)
|
||||
|
||||
async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail:
|
||||
@@ -210,6 +219,7 @@ class OperationalTaskService:
|
||||
for document in await self.repo.list_evidence(str(task_id))
|
||||
]
|
||||
actions = await self.repo.list_actions(str(task_id))
|
||||
access_requests = await self.repo.list_access_requests(str(task_id)) if hasattr(self.repo, "list_access_requests") else []
|
||||
active_run = await self._active_run(task)
|
||||
work_order_snapshot = None
|
||||
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
|
||||
@@ -225,6 +235,7 @@ class OperationalTaskService:
|
||||
latest_report=report,
|
||||
evidence=evidence,
|
||||
actions=actions,
|
||||
access_requests=access_requests,
|
||||
permissions=self._permission_view(),
|
||||
work_order_snapshot=work_order_snapshot,
|
||||
)
|
||||
@@ -238,11 +249,130 @@ class OperationalTaskService:
|
||||
])
|
||||
if SCADA_VIEW in self.permissions:
|
||||
scopes.extend(["scada_devices", "scada_readings"])
|
||||
if hasattr(self.repo, "list_access_requests"):
|
||||
for request in await self.repo.list_access_requests(str(task_id)):
|
||||
if request["status"] == "approved":
|
||||
scopes.extend(request["approved_scopes"])
|
||||
return AgentTaskContext(
|
||||
task=await self.detail(task_id),
|
||||
approved_data_scopes=scopes,
|
||||
approved_data_scopes=list(dict.fromkeys(scopes)),
|
||||
)
|
||||
|
||||
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
|
||||
task = await self._task(task_id)
|
||||
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
|
||||
if permission in self.permissions:
|
||||
return True
|
||||
if not hasattr(self.repo, "list_access_requests"):
|
||||
return False
|
||||
active_run = await self._active_run(task)
|
||||
if active_run is None or active_run.get("status") not in {"claimed", "running"}:
|
||||
return False
|
||||
if not active_run.get("agent_session_id"):
|
||||
return False
|
||||
self._require_active_run_lease(active_run, self._now())
|
||||
return any(
|
||||
row["status"] == "approved" and source in row["approved_scopes"]
|
||||
for row in await self.repo.list_access_requests(str(task_id))
|
||||
)
|
||||
|
||||
async def request_access(self, task_id: UUID, command: TaskAccessRequestCreate) -> TaskAccessRequest:
|
||||
body = _json(command)
|
||||
scope = f"task:{task_id}:access:request"
|
||||
async with self.repo.transaction():
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return TaskAccessRequest.model_validate(replay["access_request"])
|
||||
availability = getattr(self.repo, "access_requests_available", None)
|
||||
require(
|
||||
availability is None or await availability(),
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"Task data access requests are not enabled for this project database",
|
||||
status=503,
|
||||
)
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
task = await self._task(task_id)
|
||||
active_run = await self._active_run(task)
|
||||
require(active_run is not None and active_run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Current Agent session must own the task run", status=409)
|
||||
self._require_active_run_lease(active_run, self._now())
|
||||
pending = [
|
||||
item for item in await self.repo.list_access_requests(str(task_id))
|
||||
if item["status"] == "pending"
|
||||
]
|
||||
require(
|
||||
not pending,
|
||||
"ACCESS_REQUEST_PENDING",
|
||||
"Resolve the current task data access request before creating another",
|
||||
refs=[item["access_request_id"] for item in pending],
|
||||
status=409,
|
||||
)
|
||||
now = self._now()
|
||||
request = TaskAccessRequest(
|
||||
access_request_id=uuid4(), task_id=task_id, node_id=command.node_id,
|
||||
scopes=list(dict.fromkeys(command.scopes)), reason=command.reason,
|
||||
status="pending", approved_scopes=[], version=1,
|
||||
requested_by=self._actor(), requested_at=now,
|
||||
)
|
||||
await self.repo.insert_access_request(str(task_id), _json(request))
|
||||
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||
await self._task_event(task, now)
|
||||
await self._notify(task_id, "access_approval_pending", task["title"], command.reason, now,
|
||||
required_permission=OPERATIONAL_TASK_ACCESS_APPROVE,
|
||||
subject_type="access_request", subject_id=request.access_request_id)
|
||||
await self._save_request(scope, command.request_id, body, {"access_request": _json(request)})
|
||||
return request
|
||||
|
||||
async def decide_access(self, task_id: UUID, access_request_id: UUID,
|
||||
command: TaskAccessRequestDecision) -> TaskAccessRequest:
|
||||
body = _json(command)
|
||||
scope = f"task:{task_id}:access:{access_request_id}:decision"
|
||||
async with self.repo.transaction():
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return TaskAccessRequest.model_validate(replay["access_request"])
|
||||
require(OPERATIONAL_TASK_ACCESS_APPROVE in self.permissions, "PERMISSION_DENIED",
|
||||
"Current user cannot approve task data access", status=403)
|
||||
await self.repo.lock(f"access:{access_request_id}")
|
||||
raw = await self.repo.get_access_request(str(task_id), str(access_request_id))
|
||||
require(raw is not None, "ACCESS_REQUEST_NOT_FOUND", "Access request not found", status=404)
|
||||
request = TaskAccessRequest.model_validate(raw)
|
||||
require(request.version == command.expected_version, "VERSION_CONFLICT",
|
||||
"Access request version changed", status=409)
|
||||
require(request.status == "pending", "INVALID_TRANSITION",
|
||||
"Access request has already been decided", status=409)
|
||||
approved = set(command.approved_scopes)
|
||||
require(approved <= set(request.scopes), "ACCESS_SCOPE_INVALID",
|
||||
"Approved scopes must be requested", status=422)
|
||||
require((command.decision == "approved") == bool(approved), "ACCESS_SCOPE_INVALID",
|
||||
"Approval needs at least one scope; rejection grants none", status=422)
|
||||
for source in approved:
|
||||
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
|
||||
require(permission in self.permissions, "PERMISSION_DENIED",
|
||||
"Approver cannot read a requested source", status=403)
|
||||
now = self._now()
|
||||
updated = request.model_copy(update={
|
||||
"version": request.version + 1, "status": command.decision,
|
||||
"approved_scopes": sorted(approved), "decided_by": self._actor(),
|
||||
"decided_at": now, "comment": command.comment,
|
||||
})
|
||||
await self.repo.update_access_request(str(task_id), _json(updated), request.version)
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["access_approval_pending"],
|
||||
subject_type="access_request",
|
||||
subject_id=access_request_id,
|
||||
now=now,
|
||||
reason=f"access_request_{command.decision}",
|
||||
)
|
||||
task = await self._task(task_id)
|
||||
await self._requeue_task(task, now)
|
||||
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||
await self._task_event(task, now)
|
||||
await self._save_request(scope, command.request_id, body, {"access_request": _json(updated)})
|
||||
return updated
|
||||
|
||||
async def list_runs(
|
||||
self, *, status: str | None, limit: int,
|
||||
cursor_created_at=None, cursor_run_id=None,
|
||||
@@ -305,6 +435,14 @@ class OperationalTaskService:
|
||||
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||
await self._run_event(run_model, now)
|
||||
await self._task_event(task, now)
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["run_delivery_degraded"],
|
||||
subject_type="task",
|
||||
subject_id=task_id,
|
||||
now=now,
|
||||
reason="run_claimed",
|
||||
)
|
||||
await self._save_request(scope, command.request_id, body, {"run": _json(run_model)})
|
||||
return run_model
|
||||
|
||||
@@ -420,6 +558,14 @@ class OperationalTaskService:
|
||||
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||
await self._run_event(run, now, queued=True)
|
||||
await self._task_event(task, now)
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["task_failed", "run_delivery_degraded"],
|
||||
subject_type="task",
|
||||
subject_id=task_id,
|
||||
now=now,
|
||||
reason="task_retried",
|
||||
)
|
||||
await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
|
||||
return await self.detail(task_id)
|
||||
|
||||
@@ -572,6 +718,35 @@ class OperationalTaskService:
|
||||
known_evidence,
|
||||
)
|
||||
self._validate_plan_state(command.plan, known_evidence, actions)
|
||||
if command.plan.schema_name == "execution-plan@2":
|
||||
requests = {
|
||||
row["access_request_id"]: TaskAccessRequest.model_validate(row)
|
||||
for row in await self.repo.list_access_requests(str(task_id))
|
||||
}
|
||||
for node in command.plan.nodes:
|
||||
if node.kind == "gate":
|
||||
gate_ref = str(node.gate_ref)
|
||||
access_request = requests.get(gate_ref)
|
||||
action = actions.get(gate_ref)
|
||||
require(
|
||||
access_request is not None or action is not None,
|
||||
"GATE_REFERENCE_NOT_FOUND",
|
||||
"Gate must reference a persisted request or action",
|
||||
refs=[node.id], status=409,
|
||||
)
|
||||
if node.status == "completed":
|
||||
require(
|
||||
(
|
||||
access_request is not None
|
||||
and access_request.status != "pending"
|
||||
) or (
|
||||
action is not None
|
||||
and action.approval_status != "pending"
|
||||
),
|
||||
"GATE_NOT_RESOLVED",
|
||||
"A completed gate requires a completed human decision",
|
||||
refs=[node.id], status=409,
|
||||
)
|
||||
plan_revision = (plan_revision or 0) + 1
|
||||
snapshot = ExecutionPlanSnapshot(
|
||||
revision=plan_revision,
|
||||
@@ -657,6 +832,7 @@ class OperationalTaskService:
|
||||
return HumanApprovalAction.model_validate(action)
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
task = await self._task(task_id)
|
||||
self._require_actionable_task(task)
|
||||
self._validate_action_payload(command)
|
||||
if command.node_id:
|
||||
latest_plan = await self.repo.get_latest_plan(str(task_id))
|
||||
@@ -699,12 +875,63 @@ class OperationalTaskService:
|
||||
await self._notify(
|
||||
task_id, "action_approval_pending", task["title"], command.reason, now,
|
||||
required_permission=approval_permission,
|
||||
subject_type="action", subject_id=action.action_id,
|
||||
)
|
||||
await self._save_request(
|
||||
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
||||
)
|
||||
return action
|
||||
|
||||
async def update_work_order_draft(
|
||||
self, task_id: UUID, action_id: UUID, command: WorkOrderDraftUpdateCommand
|
||||
) -> HumanApprovalAction:
|
||||
body = _json(command)
|
||||
scope = f"task:{task_id}:action:{action_id}:draft"
|
||||
async with self.repo.transaction():
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return HumanApprovalAction.model_validate(replay["action"])
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
await self.repo.lock(f"action:{action_id}")
|
||||
self._require_actionable_task(await self._task(task_id))
|
||||
action = await self._action(task_id, action_id)
|
||||
require(action.kind == "create_work_order", "INVALID_ACTION_KIND",
|
||||
"Only work-order drafts can be edited", status=422)
|
||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||
"Action version changed", status=409)
|
||||
require(action.approval_status == "pending" and action.execution_status == "not_submitted",
|
||||
"INVALID_TRANSITION", "Submitted actions cannot be edited", status=409)
|
||||
require(OPERATIONAL_ACTION_WORK_ORDER_APPROVE in self.permissions,
|
||||
"PERMISSION_DENIED", "Current user cannot edit this work-order draft", status=403)
|
||||
now = self._now()
|
||||
updated = action.model_copy(update={
|
||||
"version": action.version + 1, "payload": command.payload,
|
||||
"edited_by": self._actor(), "edited_at": now,
|
||||
})
|
||||
await self.repo.update_action(str(task_id), _json(updated))
|
||||
await self._action_event(updated, now)
|
||||
await self._save_request(scope, command.request_id, body, {"action": _json(updated)})
|
||||
return updated
|
||||
|
||||
async def confirm_work_order_dispatch(
|
||||
self, task_id: UUID, action_id: UUID, command: WorkOrderConfirmDispatchCommand,
|
||||
dispatcher, credential: str | None,
|
||||
) -> HumanApprovalAction:
|
||||
require(OPERATIONAL_ACTION_WORK_ORDER_APPROVE in self.permissions
|
||||
and OPERATIONAL_ACTION_WORK_ORDER_EXECUTE in self.permissions,
|
||||
"PERMISSION_DENIED", "Current user cannot confirm and dispatch work orders", status=403)
|
||||
current = await self._action(task_id, action_id)
|
||||
require(current.kind == "create_work_order", "INVALID_ACTION_KIND",
|
||||
"Only work orders support one-step confirmation", status=422)
|
||||
approval = await self.decide_action(task_id, action_id, ActionDecisionCommand(
|
||||
request_id=uuid5(NAMESPACE_URL, f"confirm:{command.request_id}:approve"),
|
||||
expected_version=command.expected_version, decision="approved",
|
||||
))
|
||||
return await self.submit_action(task_id, action_id, ActionExecuteCommand(
|
||||
request_id=uuid5(NAMESPACE_URL, f"confirm:{command.request_id}:dispatch"),
|
||||
expected_version=approval.version,
|
||||
), dispatcher, credential)
|
||||
|
||||
async def decide_action(
|
||||
self, task_id: UUID, action_id: UUID, command: ActionDecisionCommand
|
||||
) -> HumanApprovalAction:
|
||||
@@ -714,7 +941,9 @@ class OperationalTaskService:
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return HumanApprovalAction.model_validate(replay["action"])
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
await self.repo.lock(f"action:{action_id}")
|
||||
self._require_actionable_task(await self._task(task_id))
|
||||
action = await self._action(task_id, action_id)
|
||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||
"Action version changed", status=409)
|
||||
@@ -730,6 +959,14 @@ class OperationalTaskService:
|
||||
})
|
||||
await self.repo.update_action(str(task_id), _json(action))
|
||||
await self._action_event(action, now)
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["action_pending", "action_approval_pending"],
|
||||
subject_type="action",
|
||||
subject_id=action_id,
|
||||
now=now,
|
||||
reason=f"action_{command.decision}",
|
||||
)
|
||||
if command.decision == "rejected":
|
||||
task = await self._task(task_id)
|
||||
now = self._now()
|
||||
@@ -749,7 +986,9 @@ class OperationalTaskService:
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return HumanApprovalAction.model_validate(replay["action"])
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
await self.repo.lock(f"action:{action_id}")
|
||||
self._require_actionable_task(await self._task(task_id))
|
||||
action = await self._action(task_id, action_id)
|
||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||
"Action version changed", status=409)
|
||||
@@ -771,6 +1010,14 @@ class OperationalTaskService:
|
||||
})
|
||||
await self.repo.update_action(str(task_id), _json(action))
|
||||
await self._action_event(action, now)
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["external_coordination_failed"],
|
||||
subject_type="action",
|
||||
subject_id=action_id,
|
||||
now=now,
|
||||
reason="external_coordination_retried",
|
||||
)
|
||||
await self._save_request(
|
||||
scope, command.request_id, body, {"action": _json(action)}
|
||||
)
|
||||
@@ -795,6 +1042,7 @@ class OperationalTaskService:
|
||||
"外部系统提交中断,执行结果未知,请核对回执",
|
||||
self._now(),
|
||||
required_permission=action.required_execution_permission,
|
||||
subject_type="action", subject_id=action_id,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -822,6 +1070,16 @@ class OperationalTaskService:
|
||||
outcome.detail,
|
||||
outcome_recorded_at,
|
||||
required_permission=action.required_execution_permission,
|
||||
subject_type="action", subject_id=action_id,
|
||||
)
|
||||
elif outcome.status == "succeeded":
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["external_coordination_failed"],
|
||||
subject_type="action",
|
||||
subject_id=action_id,
|
||||
now=outcome_recorded_at,
|
||||
reason="external_coordination_succeeded",
|
||||
)
|
||||
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
||||
task_document = await self._task(task_id)
|
||||
@@ -883,6 +1141,16 @@ class OperationalTaskService:
|
||||
outcome.detail,
|
||||
outcome_recorded_at,
|
||||
required_permission=action.required_execution_permission,
|
||||
subject_type="action", subject_id=action_id,
|
||||
)
|
||||
elif outcome.status == "succeeded":
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["external_coordination_failed"],
|
||||
subject_type="action",
|
||||
subject_id=action_id,
|
||||
now=outcome_recorded_at,
|
||||
reason="external_coordination_succeeded",
|
||||
)
|
||||
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
||||
task_document = await self._task(task_id)
|
||||
@@ -988,6 +1256,24 @@ class OperationalTaskService:
|
||||
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||
await self._task_event(task, now)
|
||||
if task["status"] != "failed":
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["task_failed"],
|
||||
subject_type="task",
|
||||
subject_id=task_id,
|
||||
now=now,
|
||||
reason="external_status_recovered",
|
||||
)
|
||||
if task["status"] != "waiting_human":
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["human_input_required"],
|
||||
subject_type="task",
|
||||
subject_id=task_id,
|
||||
now=now,
|
||||
reason="external_status_progressed",
|
||||
)
|
||||
if task["task_type"] == "external_work_order":
|
||||
await self._append_event(
|
||||
topic="workbench.work_order.changed",
|
||||
@@ -1010,6 +1296,88 @@ class OperationalTaskService:
|
||||
await self._save_request(scope, command.request_id, body, _json_dict(response))
|
||||
return ExternalFeedbackResult(task=await self.detail(task_id), **response)
|
||||
|
||||
async def withdraw_external_source(
|
||||
self, task_id: UUID, command: ExternalSourceWithdrawalCommand
|
||||
) -> OperationalTaskDetail:
|
||||
body = _json(command)
|
||||
scope = f"task:{task_id}:withdraw-external-source"
|
||||
async with self.repo.transaction():
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return await self.detail(task_id)
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
task = await self._task(task_id)
|
||||
source = task["source"]
|
||||
require(
|
||||
task["task_type"] == "external_work_order"
|
||||
and source.get("kind") == "external_system"
|
||||
and source.get("external_id") == command.source_event_id
|
||||
and command.source_event_id.startswith("peripheral_sample:"),
|
||||
"EXTERNAL_SOURCE_MISMATCH",
|
||||
"Only matching peripheral work orders can be withdrawn",
|
||||
status=409,
|
||||
)
|
||||
if source.get("deleted_at") is None:
|
||||
now = self._now()
|
||||
task["source"] = {**source, "deleted_at": now}
|
||||
task.update(
|
||||
status="cancelled",
|
||||
lifecycle_status="cancelled",
|
||||
revision=task["revision"] + 1,
|
||||
updated_at=now,
|
||||
)
|
||||
await self.repo.update_task(
|
||||
_task_json(OperationalTaskSummary.model_validate(task))
|
||||
)
|
||||
await self._task_event(task, now)
|
||||
await self._append_event(
|
||||
topic="workbench.work_order.changed",
|
||||
aggregate_type="operational_task",
|
||||
aggregate_id=str(task_id),
|
||||
aggregate_revision=task["revision"],
|
||||
occurred_at=now,
|
||||
metadata={"task_id": str(task_id), "namespace": "operational-task"},
|
||||
)
|
||||
for action in await self.repo.list_actions(str(task_id)):
|
||||
action_id = UUID(str(action["action_id"]))
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["action_pending", "action_approval_pending"],
|
||||
subject_type="action",
|
||||
subject_id=action_id,
|
||||
now=now,
|
||||
reason="external_source_withdrawn",
|
||||
)
|
||||
if action["execution_status"] == "not_submitted":
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["external_coordination_failed"],
|
||||
subject_type="action",
|
||||
subject_id=action_id,
|
||||
now=now,
|
||||
reason="external_source_withdrawn",
|
||||
)
|
||||
if hasattr(self.repo, "list_access_requests"):
|
||||
for access_request in await self.repo.list_access_requests(str(task_id)):
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["access_approval_pending"],
|
||||
subject_type="access_request",
|
||||
subject_id=UUID(str(access_request["access_request_id"])),
|
||||
now=now,
|
||||
reason="external_source_withdrawn",
|
||||
)
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["human_input_required", "run_delivery_degraded", "task_failed"],
|
||||
subject_type="task",
|
||||
subject_id=task_id,
|
||||
now=now,
|
||||
reason="external_source_withdrawn",
|
||||
)
|
||||
await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
|
||||
return await self.detail(task_id)
|
||||
|
||||
async def acknowledge_notification(
|
||||
self, notification_id: UUID, command: NotificationAcknowledgeCommand
|
||||
) -> OperationalTaskNotification:
|
||||
@@ -1047,8 +1415,8 @@ class OperationalTaskService:
|
||||
)
|
||||
await self._append_event(
|
||||
topic="operational.notification.changed",
|
||||
aggregate_type="operational_notification",
|
||||
aggregate_id=str(notification_id),
|
||||
aggregate_type="operational_notification_read",
|
||||
aggregate_id=f"{notification_id}:{self.context.user_id}",
|
||||
aggregate_revision=notification.version,
|
||||
occurred_at=notification.read_at,
|
||||
metadata={"task_id": str(notification.task_id)},
|
||||
@@ -1121,6 +1489,14 @@ class OperationalTaskService:
|
||||
await self._requeue_task(task, now)
|
||||
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||
await self._task_event(task, now)
|
||||
await self._resolve_notifications(
|
||||
task_id,
|
||||
kinds=["human_input_required"],
|
||||
subject_type="task",
|
||||
subject_id=task_id,
|
||||
now=now,
|
||||
reason="human_evidence_added",
|
||||
)
|
||||
await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
|
||||
return await self.detail(task_id)
|
||||
|
||||
@@ -1289,7 +1665,13 @@ class OperationalTaskService:
|
||||
HumanApprovalAction.model_validate(item)
|
||||
for item in await self.repo.list_actions(str(task_id))
|
||||
]
|
||||
lifecycle = self._status_from_plan(plan.plan.nodes, actions)
|
||||
access_requests = [
|
||||
TaskAccessRequest.model_validate(item)
|
||||
for item in await self.repo.list_access_requests(str(task_id))
|
||||
]
|
||||
lifecycle = self._status_from_plan(
|
||||
plan.plan.nodes, actions, access_requests
|
||||
)
|
||||
if lifecycle == "running":
|
||||
final_outcome = "failed"
|
||||
lifecycle = "failed"
|
||||
@@ -1359,6 +1741,16 @@ class OperationalTaskService:
|
||||
require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def _require_actionable_task(task: dict) -> None:
|
||||
source = task.get("source") or {}
|
||||
require(
|
||||
task.get("lifecycle_status") != "cancelled" and source.get("deleted_at") is None,
|
||||
"TASK_NOT_ACTIONABLE",
|
||||
"Cancelled or withdrawn tasks do not accept action changes",
|
||||
status=409,
|
||||
)
|
||||
|
||||
async def _action(self, task_id: UUID, action_id: UUID) -> HumanApprovalAction:
|
||||
value = await self.repo.get_action(str(task_id), str(action_id))
|
||||
require(value is not None, "ACTION_NOT_FOUND", "Action not found", status=404)
|
||||
@@ -1385,7 +1777,11 @@ class OperationalTaskService:
|
||||
return uuid5(NAMESPACE_URL, f"operational-task:{task_id}:generation:{generation}")
|
||||
|
||||
@staticmethod
|
||||
def _status_from_plan(nodes, actions: Iterable[HumanApprovalAction]) -> str:
|
||||
def _status_from_plan(
|
||||
nodes,
|
||||
actions: Iterable[HumanApprovalAction],
|
||||
access_requests: Iterable[TaskAccessRequest] = (),
|
||||
) -> str:
|
||||
statuses = {node.status for node in nodes}
|
||||
if "running" in statuses:
|
||||
return "running"
|
||||
@@ -1396,6 +1792,8 @@ class OperationalTaskService:
|
||||
return "failed"
|
||||
if any(action.approval_status == "pending" for action in actions):
|
||||
return "waiting_human"
|
||||
if any(request.status == "pending" for request in access_requests):
|
||||
return "waiting_human"
|
||||
if statuses <= {"completed", "skipped", "cancelled"}:
|
||||
return "completed"
|
||||
if "failed" in statuses and statuses <= {"completed", "failed", "skipped", "cancelled"}:
|
||||
@@ -1420,7 +1818,10 @@ class OperationalTaskService:
|
||||
"A running or completed node has incomplete predecessors",
|
||||
refs=incomplete, status=409)
|
||||
if node.status == "completed":
|
||||
require(bool(node.evidence_refs or node.action_refs),
|
||||
structural = plan.schema_name == "execution-plan@2" and node.kind in {
|
||||
"parallel_split", "parallel_join", "gate"
|
||||
}
|
||||
require(structural or bool(node.evidence_refs or node.action_refs),
|
||||
"PLAN_NODE_UNSUPPORTED",
|
||||
"Completed nodes require evidence or an executed action receipt",
|
||||
refs=[node.id], status=409)
|
||||
@@ -1553,11 +1954,14 @@ class OperationalTaskService:
|
||||
async def _notify(
|
||||
self, task_id: UUID, kind: str, title: str, description: str, now: datetime,
|
||||
*, required_permission: str | None = None,
|
||||
subject_type: str = "task", subject_id: UUID | None = None,
|
||||
) -> None:
|
||||
notification = OperationalTaskNotification(
|
||||
notification_id=uuid4(), task_id=task_id, kind=kind,
|
||||
title=title, description=description, status="unread", version=1,
|
||||
created_at=now, required_permission=required_permission,
|
||||
lifecycle_status="active", subject_type=subject_type,
|
||||
subject_id=subject_id or task_id,
|
||||
)
|
||||
await self.repo.insert_notification(_json(notification))
|
||||
await self._append_event(
|
||||
@@ -1569,6 +1973,42 @@ class OperationalTaskService:
|
||||
metadata={"task_id": str(task_id), "kind": kind},
|
||||
)
|
||||
|
||||
async def _resolve_notifications(
|
||||
self,
|
||||
task_id: UUID,
|
||||
*,
|
||||
kinds: list[str],
|
||||
subject_type: str,
|
||||
subject_id: UUID,
|
||||
now: datetime,
|
||||
reason: str,
|
||||
) -> None:
|
||||
resolve = getattr(self.repo, "resolve_notifications", None)
|
||||
if resolve is None:
|
||||
return
|
||||
notifications = await resolve(
|
||||
str(task_id),
|
||||
kinds=kinds,
|
||||
subject_type=subject_type,
|
||||
subject_id=str(subject_id),
|
||||
resolved_at=now,
|
||||
resolution_reason=reason,
|
||||
)
|
||||
for notification in notifications:
|
||||
await self._append_event(
|
||||
topic="operational.notification.changed",
|
||||
aggregate_type="operational_notification",
|
||||
aggregate_id=notification["notification_id"],
|
||||
aggregate_revision=2,
|
||||
occurred_at=now,
|
||||
metadata={
|
||||
"task_id": str(task_id),
|
||||
"kind": notification["kind"],
|
||||
"change": "resolved",
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
|
||||
def _new_run(
|
||||
self, task_id: UUID, generation: int, now: datetime
|
||||
) -> OperationalTaskRun:
|
||||
|
||||
@@ -5,10 +5,7 @@ from datetime import datetime, timezone
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from app.domain.schemas.workbench_runtime import (
|
||||
WorkbenchPersistedAlert,
|
||||
WorkbenchRuntimeWorkOrder,
|
||||
)
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchPersistedAlert
|
||||
|
||||
_ANALYSIS_TYPE_LABELS = {
|
||||
"burst_analysis": "爆管分析",
|
||||
@@ -39,14 +36,11 @@ async def build_workbench_runtime_snapshot(
|
||||
analysis_runs: list[dict[str, Any]] | None = None,
|
||||
latest_analysis_results: list[dict[str, Any]] | None = None,
|
||||
latest_analysis_map_payload: dict[str, Any] | None = None,
|
||||
work_order_storage_configured: bool = False,
|
||||
work_orders: list[dict[str, Any]] | None = None,
|
||||
alert_storage_configured: bool = False,
|
||||
persisted_alerts: list[dict[str, Any]] | None = None,
|
||||
scenario_availability: str | None = None,
|
||||
artifact_availability: str | None = None,
|
||||
schedule_availability: str | None = None,
|
||||
work_order_availability: str | None = None,
|
||||
alert_availability: str | None = None,
|
||||
projection_degraded: bool = False,
|
||||
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
@@ -62,14 +56,6 @@ async def build_workbench_runtime_snapshot(
|
||||
if persisted_analysis_runs
|
||||
else None
|
||||
)
|
||||
persisted_work_orders = [
|
||||
WorkbenchRuntimeWorkOrder.model_validate(item).model_dump()
|
||||
for item in (
|
||||
(work_orders or [])[:100]
|
||||
if work_order_storage_configured
|
||||
else []
|
||||
)
|
||||
]
|
||||
stored_alerts = [
|
||||
WorkbenchPersistedAlert.model_validate(item)
|
||||
for item in (
|
||||
@@ -104,9 +90,7 @@ async def build_workbench_runtime_snapshot(
|
||||
"ready" if latest_analysis else "not_configured"
|
||||
)
|
||||
resolved_schedule_availability = schedule_availability or "not_configured"
|
||||
resolved_work_order_availability = work_order_availability or (
|
||||
"ready" if work_order_storage_configured else "not_configured"
|
||||
)
|
||||
resolved_work_order_availability = "not_configured"
|
||||
resolved_alert_availability = alert_availability or (
|
||||
"ready" if alert_storage_configured else "not_configured"
|
||||
)
|
||||
@@ -125,7 +109,6 @@ async def build_workbench_runtime_snapshot(
|
||||
"ready"
|
||||
if (
|
||||
source != "none"
|
||||
or work_order_storage_configured
|
||||
or alert_storage_configured
|
||||
)
|
||||
else "not_configured"
|
||||
@@ -141,7 +124,7 @@ async def build_workbench_runtime_snapshot(
|
||||
"alerts": projected_alerts,
|
||||
"scenarios": scenarios,
|
||||
"latest_analysis": latest_analysis,
|
||||
"work_orders": persisted_work_orders,
|
||||
"work_orders": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user