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
+381
View File
@@ -5,11 +5,14 @@ from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.auth.permissions import (
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
OPERATIONAL_TASK_VIEW,
OPERATIONAL_TASK_ACCESS_APPROVE,
SCADA_VIEW,
)
from app.auth.project_dependencies import ProjectContext
from app.domain.domain_errors import DomainError
@@ -20,14 +23,22 @@ from app.domain.schemas.operational_task import (
AgentArtifactsCommand,
AgentRunCommand,
ExternalFeedbackCommand,
ExternalSourceWithdrawalCommand,
HumanApprovalAction,
NotificationAcknowledgeCommand,
OperationalTaskNotification,
OperationalTaskRun,
OperationalTaskSummary,
ExecutionPlanInput,
TaskAccessRequestCreate,
TaskAccessRequestDecision,
TaskAccessRequest,
WorkOrderDraftUpdateCommand,
WorkOrderConfirmDispatchCommand,
)
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import ActionDispatchOutcome
from app.services.operational_tasks import OperationalTaskService
@@ -41,6 +52,7 @@ class NotificationRepository:
self.notification = notification
self.reads = {}
self.requests = {}
self.events = []
@asynccontextmanager
async def transaction(self):
@@ -73,6 +85,32 @@ class NotificationRepository:
assert version == 2
self.reads[user_id] = read_at
async def append_event(self, **event):
self.events.append(event)
return len(self.events)
@pytest.mark.anyio
async def test_access_request_read_keeps_existing_tasks_available_before_migration():
connection = SimpleNamespace(execute=AsyncMock())
connection.execute.return_value.fetchone = AsyncMock(return_value={"relation": None})
repository = OperationalTaskRepository(connection, uuid4())
assert await repository.list_access_requests(str(uuid4())) == []
assert connection.execute.await_count == 1
@pytest.mark.anyio
async def test_access_request_read_uses_table_after_migration():
availability = SimpleNamespace(fetchone=AsyncMock(return_value={"relation": "workbench.operational_task_access_requests"}))
records = [{"document": {"access_request_id": str(uuid4())}}]
result = SimpleNamespace(fetchall=AsyncMock(return_value=records))
connection = SimpleNamespace(execute=AsyncMock(side_effect=[availability, result]))
repository = OperationalTaskRepository(connection, uuid4())
assert await repository.list_access_requests(str(uuid4())) == [records[0]["document"]]
assert connection.execute.await_count == 2
def _context(user_id=None, *, system_role="user"):
return ProjectContext(
@@ -116,6 +154,11 @@ async def test_notification_read_state_is_isolated_per_user():
)
assert second_read.status == "read"
assert second_read.read_by == f"principal:{second.user_id}"
assert [event["aggregate_type"] for event in repository.events] == [
"operational_notification_read",
"operational_notification_read",
]
assert repository.events[0]["aggregate_id"] != repository.events[1]["aggregate_id"]
def test_running_plan_state_wins_over_future_human_waiting_node():
@@ -142,6 +185,20 @@ def test_pending_approval_prevents_plan_from_marking_task_completed():
assert OperationalTaskService._status_from_plan(nodes, [action]) == "waiting_human"
def test_pending_access_request_prevents_plan_from_marking_task_completed():
request = TaskAccessRequest(
access_request_id=uuid4(), task_id=uuid4(), node_id="read-scada",
scopes=["scada_devices"], reason="核对监测点", status="pending",
approved_scopes=[], version=1, requested_by="principal:agent",
requested_at=datetime.now(timezone.utc),
)
nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()]
assert OperationalTaskService._status_from_plan(
nodes, [], [request]
) == "waiting_human"
def test_explicit_agent_run_failure_marks_task_failed_even_with_waiting_work():
nodes = [
type("Node", (), {"id": "FIELD_SAMPLE", "status": "waiting", "owner": "human"})(),
@@ -210,6 +267,7 @@ class TaskRepository:
self.reports = []
self.notifications = []
self.locks = []
self.access_requests = {}
@asynccontextmanager
async def transaction(self):
@@ -254,6 +312,19 @@ class TaskRepository:
async def insert_evidence(self, _task_id, evidence):
self.evidence[evidence["evidence_id"]] = evidence
async def list_access_requests(self, _task_id):
return list(self.access_requests.values())
async def get_access_request(self, _task_id, access_request_id):
return self.access_requests.get(access_request_id)
async def insert_access_request(self, _task_id, document):
self.access_requests[document["access_request_id"]] = document
async def update_access_request(self, _task_id, document, expected_version):
assert document["version"] == expected_version + 1
self.access_requests[document["access_request_id"]] = document
async def list_actions(self, _task_id):
return [self.action] if self.action else []
@@ -271,6 +342,26 @@ class TaskRepository:
async def insert_notification(self, notification):
self.notifications.append(notification)
async def resolve_notifications(
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
):
resolved = []
for notification in self.notifications:
if (
notification["task_id"] == task_id
and notification["kind"] in kinds
and notification.get("lifecycle_status", "active") == "active"
and notification.get("subject_type") == subject_type
and notification.get("subject_id") == subject_id
):
notification.update(
lifecycle_status="resolved",
resolved_at=resolved_at.isoformat(),
resolution_reason=resolution_reason,
)
resolved.append(notification.copy())
return resolved
async def get_feedback(self, task_id, source_event_id):
return self.feedback.get((task_id, source_event_id))
@@ -565,6 +656,17 @@ async def test_rejected_action_requeues_task_in_a_new_execution_generation():
assert repository.task["execution_request_id"] != original_request_id
assert repository.task["agent_session_id"] is None
assert repository.task["agent_lease_expires_at"] is None
approval_notice = next(
item for item in repository.notifications
if item["kind"] == "action_approval_pending"
)
assert approval_notice["lifecycle_status"] == "resolved"
assert approval_notice["resolution_reason"] == "action_rejected"
assert any(
event["topic"] == "operational.notification.changed"
and event["metadata"].get("change") == "resolved"
for event in repository.events
)
@pytest.mark.anyio
@@ -600,6 +702,206 @@ async def test_action_decision_rejects_a_stale_expected_version():
assert repository.action["version"] == action.version
def test_v2_plan_requires_explicit_decision_route_and_rejects_cycles():
plan = {
"schema": "execution-plan@2", "title": "动态调查", "reason": "核对投诉与风险",
"nodes": [
{"id": "risk", "title": "判断风险", "owner": "agent", "status": "completed",
"detail": "按已核实事件选择", "kind": "decision", "selected_edge_id": "urgent",
"evidence_refs": ["risk-evidence"]},
{"id": "urgent-check", "title": "紧急采样", "owner": "human", "status": "waiting",
"detail": "等待现场人员", "kind": "activity"},
{"id": "routine-check", "title": "常规核查", "owner": "human", "status": "skipped",
"detail": "紧急路线已选", "kind": "activity"},
],
"edges": [
{"id": "urgent", "from": "risk", "to": "urgent-check", "condition": "有紧急风险"},
{"id": "routine", "from": "risk", "to": "routine-check", "condition": "无紧急风险"},
],
}
assert ExecutionPlanInput.model_validate(plan).nodes[0].selected_edge_id == "urgent"
with pytest.raises(ValidationError, match="outgoing route"):
ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "selected_edge_id": "missing"}, *plan["nodes"][1:]]})
with pytest.raises(ValidationError, match="acyclic"):
ExecutionPlanInput.model_validate({**plan, "edges": [*plan["edges"], {"id": "loop", "from": "urgent-check", "to": "risk"}]})
with pytest.raises(ValidationError, match="unchosen route"):
ExecutionPlanInput.model_validate({**plan, "nodes": [plan["nodes"][0], plan["nodes"][1],
{**plan["nodes"][2], "status": "waiting"}]})
with pytest.raises(ValidationError, match="only decision or parallel split"):
ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "kind": "activity", "selected_edge_id": None}, *plan["nodes"][1:]]})
@pytest.mark.anyio
async def test_task_access_grant_is_project_scoped_reviewable_and_requeues():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
initial_revision = task["revision"]
repository = RunTaskRepository(task, _active_run(task, now))
agent = OperationalTaskService(repository, context, set(), clock=lambda: now)
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
assert repository.task["revision"] == initial_revision + 1
assert any(event["topic"] == "operational.task.changed" for event in repository.events)
assert not await agent.can_read_source(task["task_id"], "scada_devices")
assert repository.notifications[0]["kind"] == "access_approval_pending"
reviewer = OperationalTaskService(repository, context, {OPERATIONAL_TASK_ACCESS_APPROVE, SCADA_VIEW}, clock=lambda: now)
approved = await reviewer.decide_access(task["task_id"], request.access_request_id,
TaskAccessRequestDecision(request_id=uuid4(), expected_version=1,
decision="approved", approved_scopes=["scada_devices"]))
assert approved.version == 2
assert not await agent.can_read_source(task["task_id"], "scada_devices")
repository.run.update(status="running", agent_session_id="session-2",
lease_expires_at=now + timedelta(minutes=1))
assert await agent.can_read_source(task["task_id"], "scada_devices")
assert not await agent.can_read_source(task["task_id"], "scada_readings")
assert repository.task["status"] == "queued"
access_notice = next(
item for item in repository.notifications
if item["kind"] == "access_approval_pending"
)
assert access_notice["lifecycle_status"] == "resolved"
assert access_notice["resolution_reason"] == "access_request_approved"
@pytest.mark.anyio
async def test_task_access_request_rejects_a_second_pending_request():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
with pytest.raises(DomainError) as exc:
await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="重复申请",
))
assert exc.value.code == "ACCESS_REQUEST_PENDING"
assert len(repository.access_requests) == 1
assert len(repository.notifications) == 1
@pytest.mark.anyio
async def test_task_access_request_fails_closed_before_project_migration():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
repository.access_requests_available = AsyncMock(return_value=False)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as exc:
await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
assert exc.value.code == "DEPENDENCY_UNAVAILABLE"
assert exc.value.status == 503
@pytest.mark.anyio
async def test_completed_gate_requires_access_request_decision():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
scopes=["scada_devices"], reason="核对上游监测点",
))
with pytest.raises(DomainError) as exc:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
plan={
"schema": "execution-plan@2",
"title": "读取监测数据",
"reason": "等待授权后读取",
"nodes": [{
"id": "read-scada", "title": "数据访问审核",
"owner": "human", "status": "completed",
"detail": "尚未审核", "kind": "gate",
"gate_ref": str(request.access_request_id),
}],
"edges": [],
},
))
assert exc.value.code == "GATE_NOT_RESOLVED"
@pytest.mark.anyio
async def test_work_order_draft_edit_creates_new_review_version():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
payload={"title": "旧草稿", "description": "采样", "work_order_type": "general"},
))
updated = await service.update_work_order_draft(task["task_id"], action.action_id,
WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1,
payload={"title": "人工修订草稿", "description": "增加复测", "work_order_type": "general"}))
assert updated.version == 2
assert updated.payload.title == "人工修订草稿"
assert updated.approval_status == "pending"
with pytest.raises(DomainError, match="Action version changed"):
await service.update_work_order_draft(task["task_id"], action.action_id,
WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1,
payload={"title": "过期草稿", "description": "采样", "work_order_type": "general"}))
assert repository.action["approval_status"] == "pending"
assert repository.action["version"] == updated.version
@pytest.mark.anyio
async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, {
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
}, clock=lambda: now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="现场采样", evidence_refs=[],
payload={"title": "采样", "description": "现场核验", "work_order_type": "general"},
))
class Dispatcher:
calls = 0
async def dispatch(self, **_kwargs):
self.calls += 1
return ActionDispatchOutcome("unsupported", "测试适配器未配置")
dispatcher = Dispatcher()
command = WorkOrderConfirmDispatchCommand(request_id=uuid4(), expected_version=action.version)
first = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
command, dispatcher, None)
replay = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
command, dispatcher, None)
assert first.approval_status == "approved"
assert first.execution_status == "unsupported"
assert replay.version == first.version
assert dispatcher.calls == 1
assert [item["kind"] for item in repository.notifications] == [
"action_approval_pending",
"external_coordination_failed",
]
@pytest.mark.anyio
async def test_new_payload_creates_a_new_pending_action_without_inheriting_approval():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
@@ -700,6 +1002,85 @@ async def test_external_feedback_exposes_validated_work_order_snapshot_and_refre
assert result.task.work_order_snapshot.feedback_requirements == ["上传检测结果"]
@pytest.mark.anyio
async def test_withdraw_external_source_keeps_history_and_is_idempotent():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
work_order_id = uuid4()
source_id = f"peripheral_sample:{work_order_id}"
task = _task_document(context, now, lease=None)
task.update(
task_type="external_work_order",
source={"kind": "external_system", "external_id": source_id,
"external_revision": 1, "event_kind": "general"},
)
repository = TaskRepository(task)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = ExternalSourceWithdrawalCommand(
request_id=uuid4(), source_event_id=source_id
)
withdrawn = await service.withdraw_external_source(task["task_id"], command)
replayed = await service.withdraw_external_source(task["task_id"], command)
assert withdrawn.source.deleted_at == now
assert withdrawn.status == "cancelled"
assert withdrawn.lifecycle_status == "cancelled"
assert withdrawn.revision == 3
assert replayed.revision == withdrawn.revision
assert repository.task["source"]["external_revision"] == 1
with pytest.raises(DomainError) as exc:
await service.withdraw_external_source(task["task_id"], ExternalSourceWithdrawalCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}"
))
assert exc.value.code == "EXTERNAL_SOURCE_MISMATCH"
@pytest.mark.anyio
async def test_withdrawn_external_task_rejects_action_changes_and_resolves_approval():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
work_order_id = uuid4()
source_id = f"peripheral_sample:{work_order_id}"
task = _task_document(context, now, lease=None)
task.update(
task_type="external_work_order",
status="scheduled",
source={"kind": "external_system", "external_id": source_id,
"external_revision": 1, "event_kind": "general"},
)
repository = TaskRepository(task)
service = OperationalTaskService(
repository,
context,
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
clock=lambda: now,
)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
evidence_refs=[],
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
))
await service.withdraw_external_source(
task["task_id"],
ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id),
)
with pytest.raises(DomainError) as exc:
await service.decide_action(
task["task_id"],
action.action_id,
ActionDecisionCommand(
request_id=uuid4(), expected_version=action.version, decision="approved",
),
)
assert exc.value.code == "TASK_NOT_ACTIONABLE"
assert repository.notifications[0]["lifecycle_status"] == "resolved"
assert repository.notifications[0]["resolution_reason"] == "external_source_withdrawn"
@pytest.mark.anyio
async def test_event_investigation_rejects_work_order_snapshot():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)