diff --git a/app/routes/workflows.py b/app/routes/workflows.py index 091b17a..4838b3c 100644 --- a/app/routes/workflows.py +++ b/app/routes/workflows.py @@ -578,3 +578,94 @@ async def reject_workflow_step( instance_id=instance_id, is_system_admin=current_user.get("is_system_admin", False), ) + + +# ─── G-UI-TEMPL: Template Gallery ───────────────────────────────────────────── + + +WORKFLOW_TEMPLATES = [ + { + "id": "welcome_email", + "name": "Welcome Email", + "description": "Send a welcome email when a new contact is created", + "trigger_event": "contact.after_create", + "steps": [ + {"name": "Wait 1 hour", "type": "wait", "config": {"duration_seconds": 3600}}, + {"name": "Send Welcome", "type": "mail", "config": { + "to": "{{context.email}}", + "subject": "Welcome to our service!", + "body": "Hello {{context.name}},\n\nWelcome aboard! We're excited to have you.\n\nBest regards,\nThe Team", + }}, + ], + }, + { + "id": "contact_followup", + "name": "Contact Follow-Up", + "description": "Create a follow-up task 3 days after contact creation", + "trigger_event": "contact.after_create", + "steps": [ + {"name": "Wait 3 days", "type": "wait", "config": {"duration_seconds": 259200}}, + {"name": "Create Follow-Up Task", "type": "crm", "config": { + "action": "create_contact", + "data": {"title": "Follow up with {{context.name}}", "priority": "medium"}, + }}, + {"name": "Notify Owner", "type": "notification", "config": { + "title": "Follow-up reminder", + "body": "Time to follow up with {{context.name}}", + }}, + ], + }, + { + "id": "approval_chain", + "name": "Approval Chain", + "description": "Two-step approval: manager approves, then sends notification", + "trigger_event": "manual", + "steps": [ + {"name": "Manager Approval", "type": "approval", "config": {}}, + {"name": "Send Result", "type": "mail", "config": { + "to": "{{context.initiator_email}}", + "subject": "Your request has been approved", + "body": "Your request has been approved by management.", + }}, + {"name": "Log Completion", "type": "event", "config": { + "event_name": "approval_chain.completed", + "payload": {}, + }}, + ], + }, +] + + +@router.get("/templates") +async def list_workflow_templates( + current_user: dict = Depends(require_permission("workflows:read")), +): + """List available workflow templates (G-UI-TEMPL).""" + return {"items": WORKFLOW_TEMPLATES, "total": len(WORKFLOW_TEMPLATES)} + + +@router.post("/templates/{template_id}/instantiate", status_code=status.HTTP_201_CREATED) +async def instantiate_template( + template_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workflows:write")), +): + """Instantiate a workflow template — creates a workflow from the template.""" + template = next((t for t in WORKFLOW_TEMPLATES if t["id"] == template_id), None) + if template is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"detail": "Template not found", "code": "not_found"}, + ) + + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + data = { + "name": template["name"], + "description": template["description"], + "trigger_event": template["trigger_event"], + "steps": template["steps"], + "is_active": True, + } + return await workflow_service.create_workflow(db, tenant_id, user_id, data) diff --git a/app/workflows/decision_guard.py b/app/workflows/decision_guard.py new file mode 100644 index 0000000..4d7c916 --- /dev/null +++ b/app/workflows/decision_guard.py @@ -0,0 +1,147 @@ +"""Automated-Decision Guard (G-HUMAN-DEC). + +For AI use cases configured as requiring human review, workflow/agent +results must not automatically trigger defined person-/risk-relevant +external effects before the required human review/approval policy is +fulfilled. + +This guard checks the AIUseCaseMetadata of an agent or workflow step +and blocks automatic execution of high-risk actions unless approval +has been granted. + +No blanket enforcement for normal CRM automation — only applies to +explicitly configured AI use cases. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +# Risk levels for automated decisions +RISK_LEVELS = { + "none": 0, + "low": 1, + "medium": 2, + "high": 3, + "critical": 4, +} + +# Actions that require human review when risk_level >= medium +HIGH_RISK_ACTIONS = { + "send_email", + "send_external_message", + "delete_entity", + "modify_permissions", + "execute_payment", + "publish_content", + "modify_contract", + "data_export", +} + + +def requires_human_review( + ai_use_case_metadata: dict[str, Any] | None, + action: str, +) -> bool: + """Check whether an action requires human review based on AI use case config. + + Args: + ai_use_case_metadata: The ai_use_case_metadata from AgentDefinition + or workflow step config. May contain: + - ``risk_level``: none|low|medium|high|critical + - ``requires_approval``: bool + - ``auto_execute``: bool (if False, always requires review) + - ``reviewed_actions``: list of actions that need review + action: The action being performed (e.g. "send_email", "delete_entity"). + + Returns: + True if human review is required before the action can execute. + """ + if not ai_use_case_metadata: + return False # No AI use case config → no guard + + # If auto_execute is explicitly False, always require review + if ai_use_case_metadata.get("auto_execute") is False: + return True + + # If requires_approval is True, always require review + if ai_use_case_metadata.get("requires_approval") is True: + return True + + # Check risk level + risk_level = ai_use_case_metadata.get("risk_level", "none") + risk_score = RISK_LEVELS.get(risk_level, 0) + + # Medium or higher risk always requires review + if risk_score >= RISK_LEVELS["medium"]: + return True + + # Check if this specific action is in the reviewed_actions list + reviewed_actions = ai_use_case_metadata.get("reviewed_actions", []) + if action in reviewed_actions: + return True + + # Check if action is inherently high-risk + if action in HIGH_RISK_ACTIONS and risk_score >= RISK_LEVELS["low"]: + return True + + return False + + +async def check_decision_guard( + db: AsyncSession, + tenant_id: uuid.UUID, + instance_id: uuid.UUID, + step_config: dict[str, Any], + action: str, + ai_use_case_metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Check the automated-decision guard for a workflow step. + + Returns a dict with: + - ``allowed``: bool — whether the action can proceed automatically + - ``requires_approval``: bool — whether human approval is needed + - ``reason``: str — explanation if blocked + - ``approval_action``: str — action to create approval for + + If the guard blocks, the workflow engine should pause and create + an ApprovalRequest instead of executing the action. + """ + # Merge step-level and agent-level metadata + step_metadata = step_config.get("ai_use_case_metadata") + metadata = ai_use_case_metadata or step_metadata + + if not requires_human_review(metadata, action): + return { + "allowed": True, + "requires_approval": False, + "reason": None, + } + + # Human review required + risk_level = (metadata or {}).get("risk_level", "medium") + return { + "allowed": False, + "requires_approval": True, + "reason": ( + f"Action '{action}' requires human review " + f"(risk level: {risk_level})" + ), + "approval_action": action, + "risk_level": risk_level, + } + + +__all__ = [ + "requires_human_review", + "check_decision_guard", + "RISK_LEVELS", + "HIGH_RISK_ACTIONS", +] diff --git a/app/workflows/workstream.py b/app/workflows/workstream.py new file mode 100644 index 0000000..55f7c56 --- /dev/null +++ b/app/workflows/workstream.py @@ -0,0 +1,221 @@ +"""Workflow workstream integration — posts workflow events to the central +Communication system (G-WORK). + +Replaces the old notification-based workflow messages with typed +CommMessage blocks: status, handoff, approval, action, and error. + +Used by the WorkflowEngine to post step transitions, approvals, +errors, and completions to the workstream. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +async def post_workflow_status( + db: AsyncSession, + tenant_id: uuid.UUID, + instance_id: uuid.UUID, + workflow_name: str, + status: str, + step_index: int | None = None, + step_name: str | None = None, + user_id: uuid.UUID | None = None, +) -> dict[str, Any] | None: + """Post a workflow status update to the workstream. + + Creates a CommMessage with a typed ``workflow_status`` block. + """ + try: + from app.plugins.builtins.kommunikation.contracts import KommunikationContract + contract = KommunikationContract + post_fn = contract.get_function("post_message") + if post_fn is None: + # Fallback to system notification + from app.core.notifications import post_system_message + if user_id: + await post_system_message( + db, tenant_id, user_id, "workflow_status", + f"Workflow: {workflow_name}", + f"Status: {status}" + (f" (Step: {step_name})" if step_name else ""), + ) + return None + + block = { + "type": "workflow_status", + "workflow_name": workflow_name, + "instance_id": str(instance_id), + "status": status, + "step_index": step_index, + "step_name": step_name, + } + return await post_fn( + db=db, + tenant_id=tenant_id, + sender_id=None, # System sender + sender_type="system", + message_type="workflow_status", + content=f"Workflow '{workflow_name}' → {status}", + blocks=[block], + ) + except Exception as e: + logger.warning("Failed to post workflow status to workstream: %s", e) + return None + + +async def post_workflow_handoff( + db: AsyncSession, + tenant_id: uuid.UUID, + instance_id: uuid.UUID, + workflow_name: str, + handoff_type: str, # review_needed, action_required, waiting_for_user + assignee_id: uuid.UUID | None = None, + assignee_type: str = "user", + description: str = "", + user_id: uuid.UUID | None = None, +) -> dict[str, Any] | None: + """Post a workflow handoff to the workstream. + + Creates a CommMessage with a typed ``workflow_handoff`` block. + The handoff indicates that the workflow is waiting for human input. + """ + try: + from app.plugins.builtins.kommunikation.contracts import KommunikationContract + contract = KommunikationContract + post_fn = contract.get_function("post_message") + if post_fn is None: + from app.core.notifications import post_system_message + if assignee_id: + await post_system_message( + db, tenant_id, assignee_id, "workflow_handoff", + f"Workflow Handoff: {workflow_name}", + f"{handoff_type}: {description}", + ) + return None + + block = { + "type": "workflow_handoff", + "workflow_name": workflow_name, + "instance_id": str(instance_id), + "handoff_type": handoff_type, + "assignee_id": str(assignee_id) if assignee_id else None, + "assignee_type": assignee_type, + "description": description, + } + return await post_fn( + db=db, + tenant_id=tenant_id, + sender_id=None, + sender_type="system", + message_type="workflow_handoff", + content=f"Workflow '{workflow_name}' → {handoff_type}", + blocks=[block], + ) + except Exception as e: + logger.warning("Failed to post workflow handoff to workstream: %s", e) + return None + + +async def post_workflow_error( + db: AsyncSession, + tenant_id: uuid.UUID, + instance_id: uuid.UUID, + workflow_name: str, + error: str, + step_index: int | None = None, + step_name: str | None = None, + user_id: uuid.UUID | None = None, +) -> dict[str, Any] | None: + """Post a workflow error to the workstream.""" + try: + from app.plugins.builtins.kommunikation.contracts import KommunikationContract + contract = KommunikationContract + post_fn = contract.get_function("post_message") + if post_fn is None: + from app.core.notifications import post_system_message + if user_id: + await post_system_message( + db, tenant_id, user_id, "workflow_error", + f"Workflow Error: {workflow_name}", + f"Error at step {step_name or step_index}: {error}", + ) + return None + + block = { + "type": "workflow_error", + "workflow_name": workflow_name, + "instance_id": str(instance_id), + "error": error, + "step_index": step_index, + "step_name": step_name, + } + return await post_fn( + db=db, + tenant_id=tenant_id, + sender_id=None, + sender_type="system", + message_type="workflow_error", + content=f"Workflow '{workflow_name}' → Error: {error}", + blocks=[block], + ) + except Exception as e: + logger.warning("Failed to post workflow error to workstream: %s", e) + return None + + +async def post_workflow_completed( + db: AsyncSession, + tenant_id: uuid.UUID, + instance_id: uuid.UUID, + workflow_name: str, + result: dict[str, Any] | None = None, + user_id: uuid.UUID | None = None, +) -> dict[str, Any] | None: + """Post a workflow completion to the workstream.""" + try: + from app.plugins.builtins.kommunikation.contracts import KommunikationContract + contract = KommunikationContract + post_fn = contract.get_function("post_message") + if post_fn is None: + from app.core.notifications import post_system_message + if user_id: + await post_system_message( + db, tenant_id, user_id, "workflow_completed", + f"Workflow Completed: {workflow_name}", + f"Workflow '{workflow_name}' has been completed successfully.", + ) + return None + + block = { + "type": "workflow_completed", + "workflow_name": workflow_name, + "instance_id": str(instance_id), + "result": result or {}, + } + return await post_fn( + db=db, + tenant_id=tenant_id, + sender_id=None, + sender_type="system", + message_type="workflow_completed", + content=f"Workflow '{workflow_name}' → Completed", + blocks=[block], + ) + except Exception as e: + logger.warning("Failed to post workflow completion to workstream: %s", e) + return None + + +__all__ = [ + "post_workflow_status", + "post_workflow_handoff", + "post_workflow_error", + "post_workflow_completed", +] diff --git a/docs/api-documentation.md b/docs/api-documentation.md index d50b551..a388b58 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -640,3 +640,83 @@ WebSocket and REST endpoints for AI-driven UI control. 3. **OpenAPI**: The full OpenAPI spec is available at `/openapi.json` — use it for dynamic endpoint discovery. 4. **Safe Methods**: GET endpoints are safe to probe. POST/PUT/DELETE require careful payload construction. 5. **Session Auth**: AI agents should call `POST /api/v1/auth/login` first, then use the returned cookie for all subsequent requests. + +--- + +## Phase G — Workflow MVP Endpoints + +### Workflow CRUD +| Method | Path | Description | Permission | +|--------|------|-------------|-----------| +| GET | `/api/v1/workflows` | List workflows (paginated) | `workflows:read` | +| POST | `/api/v1/workflows` | Create workflow definition | `workflows:write` | +| GET | `/api/v1/workflows/{id}` | Get workflow by ID | `workflows:read` | +| PATCH | `/api/v1/workflows/{id}` | Update workflow | `workflows:write` | +| DELETE | `/api/v1/workflows/{id}` | Delete workflow | `workflows:write` | + +### Workflow Instances +| Method | Path | Description | Permission | +|--------|------|-------------|-----------| +| GET | `/api/v1/workflows/instances` | List instances (paginated) | `workflows:read` | +| POST | `/api/v1/workflows/{id}/instances` | Create instance | `workflows:write` | +| GET | `/api/v1/workflows/instances/{id}` | Get instance by ID | `workflows:read` | +| POST | `/api/v1/workflows/instances/{id}/advance` | Advance to next step | `workflows:write` | +| POST | `/api/v1/workflows/instances/{id}/cancel` | Cancel instance | `workflows:write` | +| POST | `/api/v1/workflows/instances/{id}/resume` | Resume waiting instance (G-RUN) | `workflows:write` | +| GET | `/api/v1/workflows/instances/{id}/history` | Step history / execution log (G-LOG) | `workflows:read` | + +### Triggers (G-EVT, G-MAN, G-WEB) +| Method | Path | Description | Permission | +|--------|------|-------------|-----------| +| POST | `/api/v1/workflows/{id}/trigger` | Manual trigger (G-MAN) | `workflows:write` | +| POST | `/api/v1/workflows/webhook/{token}` | Incoming webhook trigger (G-WEB) | Token-based | + +### Approval (G-APPROVAL) +| Method | Path | Description | Permission | +|--------|------|-------------|-----------| +| POST | `/api/v1/workflows/instances/{id}/approve` | Approve current step | `workflows:write` | +| POST | `/api/v1/workflows/instances/{id}/reject` | Reject current step | `workflows:write` | + +### Templates (G-UI-TEMPL) +| Method | Path | Description | Permission | +|--------|------|-------------|-----------| +| GET | `/api/v1/workflows/templates` | List workflow templates | `workflows:read` | +| POST | `/api/v1/workflows/templates/{id}/instantiate` | Create workflow from template | `workflows:write` | + +### Step Types (14 total) +| Type | Description | Config Fields | +|------|-------------|---------------| +| `action` | Execute configured action | `action_type`, `user_id`, `notification_*` | +| `approval` | Pause for human approval | (none) | +| `notification` | Send notification | `user_id`, `title`, `body`, `notification_type` | +| `condition` | Branch on condition | `field`, `operator`, `value`, `on_true_step`, `on_false_step` | +| `wait` | Wait/delay (G-WAIT) | `duration_seconds` or `resume_at` | +| `http` | HTTP request (G-HTTP) | `method`, `url`, `headers`, `body`, `timeout_seconds`, `response_mapping` | +| `mail` | Send email (G-MAIL) | `to`, `subject`, `body`, `account_id` | +| `calendar` | Calendar event (G-CAL) | `action`, `title`, `start`, `end`, `event_id` | +| `dms` | DMS interaction (G-DMS) | `action`, `query`, `file_id` | +| `search` | Unified search (G-SEARCH) | `query`, `entity_type`, `limit` | +| `agent` | Invoke AI agent (G-AGENT) | `agent_id`, `input`, `wait_for_completion` | +| `crm` | CRM action (G-CRM) | `action`, `data`, `entity_id` | +| `event` | Publish event (G-EVT) | `event_name`, `payload` | +| `webhook` | Outgoing webhook (G-WEB) | `url`, `method`, `headers`, `body` | + +### Durable WorkflowRun (G-RUN) +- `status`: `pending` → `in_progress` → `waiting` → `in_progress` → `completed` / `failed` / `cancelled` +- `resume_at`: ISO datetime when waiting instance should be resumed +- `resume_reason`: `wait`, `approval`, `retry`, `event`, `webhook` +- `step_state`: JSONB — per-step output stored for data flow between steps (G-CTX) +- `retry_count` / `max_retries`: Retry with exponential backoff (G-RETRY) +- `lock_owner` / `lock_expires_at`: Redis lock for concurrency control +- `idempotency_key`: Deduplication for side-effect steps (G-IDEMP) +- `error_message`: Last error message if failed + +### SSRF Protection (G-HTTP) +HTTP and webhook steps block: +- Private/internal IP ranges (10.x, 172.16-31.x, 192.168.x) +- Localhost (127.0.0.1, ::1, localhost) +- Cloud metadata endpoints (metadata.google.internal) +- Non-HTTP/HTTPS schemes (ftp, file, gopher) + +### Automated-Decision Guard (G-HUMAN-DEC) +Workflow steps with `ai_use_case_metadata` containing `risk_level >= medium` or `requires_approval: true` or `auto_execute: false` require human approval before execution. High-risk actions (send_email, delete_entity, execute_payment, etc.) require review even at low risk levels. diff --git a/tests/test_phase_g_workflows.py b/tests/test_phase_g_workflows.py index 0350dab..1e1a235 100644 --- a/tests/test_phase_g_workflows.py +++ b/tests/test_phase_g_workflows.py @@ -370,3 +370,169 @@ class TestWorkflowModelDurableFields: assert hasattr(WorkflowInstance, "error_message") assert hasattr(WorkflowInstance, "retry_count") assert hasattr(WorkflowInstance, "max_retries") + + +# ─── G-HUMAN-DEC: Automated-Decision Guard ──────────────────────────────────── + + +class TestDecisionGuard: + """Test the automated-decision guard (G-HUMAN-DEC).""" + + def test_no_metadata_allows_everything(self): + """No AI use case metadata means no guard — action allowed.""" + from app.workflows.decision_guard import requires_human_review + + assert requires_human_review(None, "send_email") is False + assert requires_human_review({}, "delete_entity") is False + + def test_auto_execute_false_requires_review(self): + """auto_execute=False always requires review.""" + from app.workflows.decision_guard import requires_human_review + + metadata = {"auto_execute": False} + assert requires_human_review(metadata, "any_action") is True + + def test_requires_approval_true_requires_review(self): + """requires_approval=True always requires review.""" + from app.workflows.decision_guard import requires_human_review + + metadata = {"requires_approval": True} + assert requires_human_review(metadata, "any_action") is True + + def test_medium_risk_requires_review(self): + """Medium or higher risk level always requires review.""" + from app.workflows.decision_guard import requires_human_review + + assert requires_human_review({"risk_level": "medium"}, "any_action") is True + assert requires_human_review({"risk_level": "high"}, "any_action") is True + assert requires_human_review({"risk_level": "critical"}, "any_action") is True + + def test_low_risk_allows_normal_actions(self): + """Low risk allows normal actions.""" + from app.workflows.decision_guard import requires_human_review + + assert requires_human_review({"risk_level": "low"}, "noop") is False + assert requires_human_review({"risk_level": "none"}, "noop") is False + + def test_low_risk_blocks_high_risk_actions(self): + """Low risk still blocks inherently high-risk actions.""" + from app.workflows.decision_guard import requires_human_review + + assert requires_human_review({"risk_level": "low"}, "send_email") is True + assert requires_human_review({"risk_level": "low"}, "delete_entity") is True + assert requires_human_review({"risk_level": "low"}, "execute_payment") is True + + def test_reviewed_actions_list_checked(self): + """Specific actions in reviewed_actions list require review.""" + from app.workflows.decision_guard import requires_human_review + + metadata = {"risk_level": "none", "reviewed_actions": ["custom_action"]} + assert requires_human_review(metadata, "custom_action") is True + assert requires_human_review(metadata, "other_action") is False + + @pytest.mark.asyncio + async def test_check_decision_guard_allows_low_risk(self): + """check_decision_guard allows low-risk actions without metadata.""" + from app.workflows.decision_guard import check_decision_guard + + result = await check_decision_guard( + db=MagicMock(), + tenant_id=uuid.uuid4(), + instance_id=uuid.uuid4(), + step_config={}, + action="noop", + ai_use_case_metadata=None, + ) + assert result["allowed"] is True + assert result["requires_approval"] is False + + @pytest.mark.asyncio + async def test_check_decision_guard_blocks_high_risk(self): + """check_decision_guard blocks high-risk actions.""" + from app.workflows.decision_guard import check_decision_guard + + result = await check_decision_guard( + db=MagicMock(), + tenant_id=uuid.uuid4(), + instance_id=uuid.uuid4(), + step_config={"ai_use_case_metadata": {"risk_level": "high"}}, + action="send_email", + ai_use_case_metadata=None, + ) + assert result["allowed"] is False + assert result["requires_approval"] is True + assert "send_email" in result["reason"] + + +# ─── G-WORK: Workflow Workstream ───────────────────────────────────────────── + + +class TestWorkflowWorkstream: + """Test the workflow workstream integration (G-WORK).""" + + @pytest.mark.asyncio + async def test_post_workflow_status_fallback_to_notification(self): + """post_workflow_status falls back to system notification when CommContract unavailable.""" + from app.workflows.workstream import post_workflow_status + + with patch("app.core.notifications.post_system_message", new_callable=AsyncMock): + result = await post_workflow_status( + db=MagicMock(), + tenant_id=uuid.uuid4(), + instance_id=uuid.uuid4(), + workflow_name="Test Workflow", + status="in_progress", + user_id=uuid.uuid4(), + ) + # Should return None (fallback) but not crash + assert result is None + + @pytest.mark.asyncio + async def test_post_workflow_error_fallback_to_notification(self): + """post_workflow_error falls back to system notification.""" + from app.workflows.workstream import post_workflow_error + + with patch("app.core.notifications.post_system_message", new_callable=AsyncMock): + result = await post_workflow_error( + db=MagicMock(), + tenant_id=uuid.uuid4(), + instance_id=uuid.uuid4(), + workflow_name="Test Workflow", + error="Something went wrong", + user_id=uuid.uuid4(), + ) + assert result is None + + @pytest.mark.asyncio + async def test_post_workflow_completed_fallback_to_notification(self): + """post_workflow_completed falls back to system notification.""" + from app.workflows.workstream import post_workflow_completed + + with patch("app.core.notifications.post_system_message", new_callable=AsyncMock): + result = await post_workflow_completed( + db=MagicMock(), + tenant_id=uuid.uuid4(), + instance_id=uuid.uuid4(), + workflow_name="Test Workflow", + result={"output": "done"}, + user_id=uuid.uuid4(), + ) + assert result is None + + @pytest.mark.asyncio + async def test_post_workflow_handoff_fallback_to_notification(self): + """post_workflow_handoff falls back to system notification.""" + from app.workflows.workstream import post_workflow_handoff + + with patch("app.core.notifications.post_system_message", new_callable=AsyncMock): + result = await post_workflow_handoff( + db=MagicMock(), + tenant_id=uuid.uuid4(), + instance_id=uuid.uuid4(), + workflow_name="Test Workflow", + handoff_type="review_needed", + assignee_id=uuid.uuid4(), + description="Please review", + user_id=uuid.uuid4(), + ) + assert result is None