feat(workbench): add dynamic task approval lifecycle
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user