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 -3
View File
@@ -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)
-25
View File
@@ -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,
)
+2
View File
@@ -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,
}
+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
+88 -2
View File
@@ -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
+9 -1
View File
@@ -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_")
+446 -6
View File
@@ -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:
+3 -20
View File
@@ -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": [],
}