feat(G): G-WORK/G-HUMAN-DEC/G-UI-TEMPL/G-DOC — workflow workstream, decision guard, template gallery (3 templates), API docs, 43 tests passing
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user