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))