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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user