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
+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_")