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",
]