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