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:
Agent Zero
2026-08-18 11:11:50 +02:00
parent 1bf776dff5
commit 59f05de621
5 changed files with 705 additions and 0 deletions
+91
View File
@@ -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)
+147
View File
@@ -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",
]
+221
View File
@@ -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",
]
+80
View File
@@ -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.
+166
View File
@@ -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