dograh/api/routes/workflow.py

1139 lines
37 KiB
Python
Raw Normal View History

2025-09-09 14:37:32 +05:30
import json
import re
import uuid
2025-09-09 14:37:32 +05:30
from datetime import datetime
from typing import List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from httpx import HTTPStatusError
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
2025-09-09 14:37:32 +05:30
from api.constants import DEPLOYMENT_MODE
from api.db import db_client
from api.db.models import UserModel
from api.db.workflow_template_client import WorkflowTemplateClient
from api.enums import CallType, PostHogEvent, StorageBackend
2025-09-09 14:37:32 +05:30
from api.schemas.workflow import WorkflowRunResponseSchema
from api.services.auth.depends import get_user
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.masking import (
mask_workflow_definition,
merge_workflow_api_keys,
)
from api.services.configuration.resolve import resolve_effective_config
2025-09-09 14:37:32 +05:30
from api.services.mps_service_key_client import mps_service_key_client
from api.services.posthog_client import capture_event
from api.services.storage import storage_fs
2025-09-09 14:37:32 +05:30
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.duplicate import duplicate_workflow
2025-09-09 14:37:32 +05:30
from api.services.workflow.errors import ItemKind, WorkflowError
from api.services.workflow.workflow import WorkflowGraph
def extract_trigger_paths(workflow_definition: dict) -> List[str]:
"""Extract trigger UUIDs from workflow definition.
Args:
workflow_definition: The workflow definition JSON
Returns:
List of trigger UUIDs found in the workflow
"""
if not workflow_definition:
return []
nodes = workflow_definition.get("nodes", [])
trigger_paths = []
for node in nodes:
if node.get("type") == "trigger":
trigger_path = node.get("data", {}).get("trigger_path")
if trigger_path:
trigger_paths.append(trigger_path)
return trigger_paths
def regenerate_trigger_uuids(workflow_definition: dict) -> dict:
"""Regenerate UUIDs for all trigger nodes in a workflow definition.
This should be called when creating a new workflow from a template or
duplicating a workflow to avoid trigger UUID conflicts.
Args:
workflow_definition: The workflow definition JSON
Returns:
Updated workflow definition with new trigger UUIDs
"""
if not workflow_definition:
return workflow_definition
# Deep copy to avoid modifying the original
import copy
updated_definition = copy.deepcopy(workflow_definition)
nodes = updated_definition.get("nodes", [])
for node in nodes:
if node.get("type") == "trigger":
# Generate a new UUID for this trigger
if "data" not in node:
node["data"] = {}
node["data"]["trigger_path"] = str(uuid.uuid4())
return updated_definition
2025-09-09 14:37:32 +05:30
router = APIRouter(prefix="/workflow")
class ValidateWorkflowResponse(BaseModel):
is_valid: bool
errors: list[WorkflowError]
2026-03-23 11:47:27 +05:30
class CallDispositionCodes(BaseModel):
disposition_codes: list[str] = []
2025-09-09 14:37:32 +05:30
class WorkflowResponse(BaseModel):
id: int
name: str
status: str
created_at: datetime
workflow_definition: dict
current_definition_id: int | None
template_context_variables: dict | None = None
2026-03-23 11:47:27 +05:30
call_disposition_codes: CallDispositionCodes | None = None
2025-09-09 14:37:32 +05:30
total_runs: int | None = None
workflow_configurations: dict | None = None
version_number: int | None = None
version_status: str | None = None
2025-09-09 14:37:32 +05:30
class WorkflowListResponse(BaseModel):
"""Lightweight response for workflow listings (excludes large fields)."""
id: int
name: str
status: str
created_at: datetime
total_runs: int
class WorkflowCountResponse(BaseModel):
"""Response for workflow count endpoint."""
total: int
active: int
archived: int
2025-09-09 14:37:32 +05:30
class WorkflowTemplateResponse(BaseModel):
id: int
template_name: str
template_description: str
template_json: dict
created_at: datetime
class CreateWorkflowRequest(BaseModel):
name: str
workflow_definition: dict
class DuplicateTemplateRequest(BaseModel):
template_id: int
workflow_name: str
class UpdateWorkflowRequest(BaseModel):
2026-03-19 15:06:59 +05:30
name: str | None = None
2025-09-09 14:37:32 +05:30
workflow_definition: dict | None = None
template_context_variables: dict | None = None
workflow_configurations: dict | None = None
class WorkflowVersionResponse(BaseModel):
id: int
version_number: int
status: str
created_at: datetime
published_at: datetime | None = None
workflow_json: dict
workflow_configurations: dict | None = None
template_context_variables: dict | None = None
2025-09-09 14:37:32 +05:30
class UpdateWorkflowStatusRequest(BaseModel):
status: str # "active" or "archived"
class CreateWorkflowRunRequest(BaseModel):
mode: str
name: str
class CreateWorkflowRunResponse(BaseModel):
id: int
workflow_id: int
name: str
mode: str
created_at: datetime
definition_id: int
initial_context: dict | None = None
class CreateWorkflowTemplateRequest(BaseModel):
call_type: Literal[CallType.INBOUND.value, CallType.OUTBOUND.value]
2025-09-09 14:37:32 +05:30
use_case: str
activity_description: str
@router.post("/{workflow_id}/validate")
async def validate_workflow(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> ValidateWorkflowResponse:
"""
Validate all nodes in a workflow to ensure they have required fields.
Args:
workflow_id: The ID of the workflow to validate
user: The authenticated user
Returns:
Object indicating if workflow is valid and any invalid nodes/edges
"""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
errors: list[WorkflowError] = []
# Validate draft if it exists (user is editing), else validate published
draft = await db_client.get_draft_version(workflow_id)
workflow_definition = (
draft.workflow_json if draft else workflow.released_definition.workflow_json
)
2025-09-09 14:37:32 +05:30
# ----------- DTO Validation ------------
dto: Optional[ReactFlowDTO] = None
try:
dto = ReactFlowDTO.model_validate(workflow_definition)
except ValidationError as exc:
errors.extend(_transform_schema_errors(exc, workflow_definition))
# ----------- Graph Validation if DTO is valid ------------
try:
if dto:
WorkflowGraph(dto)
except ValueError as e:
errors.extend(e.args[0])
if errors:
raise HTTPException(
status_code=422,
detail=ValidateWorkflowResponse(is_valid=False, errors=errors).model_dump(),
)
return ValidateWorkflowResponse(is_valid=True, errors=[])
def _transform_schema_errors(
exc: ValidationError, workflow_definition: dict
) -> list[WorkflowError]:
out: list[WorkflowError] = []
for err in exc.errors():
loc = err["loc"]
idx = workflow_definition[loc[0]][loc[1]]["id"]
kind: ItemKind = ItemKind.node if loc[0] == "nodes" else ItemKind.edge
out.append(
WorkflowError(
kind=kind,
id=idx,
field=".".join(str(p) for p in err["loc"][2:]) or None,
message=err["msg"].capitalize(),
)
)
return out
@router.post("/create/definition")
async def create_workflow(
request: CreateWorkflowRequest, user: UserModel = Depends(get_user)
) -> WorkflowResponse:
"""
Create a new workflow from the client
Args:
request: The create workflow request
user: The user to create the workflow for
"""
workflow = await db_client.create_workflow(
request.name,
request.workflow_definition,
user.id,
user.selected_organization_id,
)
capture_event(
distinct_id=str(user.provider_id),
event=PostHogEvent.WORKFLOW_CREATED,
properties={
"workflow_id": workflow.id,
"workflow_name": workflow.name,
"source": "direct",
"organization_id": user.selected_organization_id,
},
)
# Sync agent triggers if workflow definition contains any
if request.workflow_definition:
trigger_paths = extract_trigger_paths(request.workflow_definition)
if trigger_paths:
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
2025-09-09 14:37:32 +05:30
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(request.workflow_definition),
2025-09-09 14:37:32 +05:30
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
@router.post("/create/template")
async def create_workflow_from_template(
request: CreateWorkflowTemplateRequest,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""
Create a new workflow from a natural language template request.
This endpoint:
1. Uses mps_service_key_client to call MPS workflow API
2. Passes organization ID (authenticated mode) or created_by (OSS mode)
3. Creates the workflow in the database
Args:
request: The template creation request with call_type, use_case, and activity_description
user: The authenticated user
Returns:
The created workflow
Raises:
HTTPException: If MPS API call fails
"""
try:
# Call MPS API to generate workflow using the client
if DEPLOYMENT_MODE == "oss":
workflow_data = await mps_service_key_client.call_workflow_api(
call_type=request.call_type.upper(),
2025-09-09 14:37:32 +05:30
use_case=request.use_case,
activity_description=request.activity_description,
created_by=str(user.provider_id),
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
workflow_data = await mps_service_key_client.call_workflow_api(
call_type=request.call_type.upper(),
2025-09-09 14:37:32 +05:30
use_case=request.use_case,
activity_description=request.activity_description,
organization_id=user.selected_organization_id,
)
# Create the workflow in our database
# Regenerate trigger UUIDs to avoid conflicts with existing triggers
workflow_def = regenerate_trigger_uuids(
workflow_data.get("workflow_definition", {})
)
2025-09-09 14:37:32 +05:30
workflow = await db_client.create_workflow(
name=workflow_data.get("name", f"{request.use_case} - {request.call_type}"),
workflow_definition=workflow_def,
2025-09-09 14:37:32 +05:30
user_id=user.id,
organization_id=user.selected_organization_id,
)
capture_event(
distinct_id=str(user.provider_id),
event=PostHogEvent.WORKFLOW_CREATED,
properties={
"workflow_id": workflow.id,
"workflow_name": workflow.name,
"source": "template",
"call_type": request.call_type,
"use_case": request.use_case,
"organization_id": user.selected_organization_id,
},
)
# Sync agent triggers if workflow definition contains any
if workflow_def:
trigger_paths = extract_trigger_paths(workflow_def)
if trigger_paths:
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
2025-09-09 14:37:32 +05:30
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(workflow_def),
2025-09-09 14:37:32 +05:30
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
except HTTPStatusError as e:
logger.error(f"MPS API error: {e}")
raise HTTPException(
status_code=e.response.status_code if hasattr(e, "response") else 500,
detail=str(e),
)
except Exception as e:
logger.error(f"Unexpected error creating workflow from template: {e}")
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred: {str(e)}",
)
class WorkflowSummaryResponse(BaseModel):
id: int
name: str
@router.get("/count")
async def get_workflow_count(
user: UserModel = Depends(get_user),
) -> WorkflowCountResponse:
"""Get workflow counts for the authenticated user's organization.
This is a lightweight endpoint for checking if the user has workflows,
useful for redirect logic without fetching full workflow data.
"""
counts = await db_client.get_workflow_counts(
organization_id=user.selected_organization_id
)
return WorkflowCountResponse(
total=counts["total"],
active=counts["active"],
archived=counts["archived"],
)
2025-09-09 14:37:32 +05:30
@router.get("/fetch")
async def get_workflows(
user: UserModel = Depends(get_user),
status: Optional[str] = Query(
None,
description="Filter by status - can be single value (active/archived) or comma-separated (active,archived)",
),
) -> List[WorkflowListResponse]:
"""Get all workflows for the authenticated user's organization.
Returns a lightweight response with only essential fields for listing.
Use GET /workflow/fetch/{workflow_id} to get full workflow details.
"""
2025-09-09 14:37:32 +05:30
# Handle comma-separated status values
if status and "," in status:
# Split comma-separated values and fetch workflows for each status
status_list = [s.strip() for s in status.split(",")]
all_workflows = []
for status_value in status_list:
workflows = await db_client.get_all_workflows_for_listing(
2025-09-09 14:37:32 +05:30
organization_id=user.selected_organization_id, status=status_value
)
all_workflows.extend(workflows)
workflows = all_workflows
else:
# Single status or no status filter
workflows = await db_client.get_all_workflows_for_listing(
2025-09-09 14:37:32 +05:30
organization_id=user.selected_organization_id, status=status
)
# Get run counts for all workflows in a single query
workflow_ids = [workflow.id for workflow in workflows]
run_counts = await db_client.get_workflow_run_counts(workflow_ids)
return [
WorkflowListResponse(
id=workflow.id,
name=workflow.name,
status=workflow.status,
created_at=workflow.created_at,
total_runs=run_counts.get(workflow.id, 0),
2025-09-09 14:37:32 +05:30
)
for workflow in workflows
]
2025-09-09 14:37:32 +05:30
@router.get("/fetch/{workflow_id}")
async def get_workflow(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""Get a single workflow by ID.
If a draft version exists, returns the draft content for editing.
Otherwise returns the published version's content.
"""
2025-09-09 14:37:32 +05:30
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
# Check for draft — editor should show draft content if it exists
draft = await db_client.get_draft_version(workflow_id)
if draft:
workflow_def = draft.workflow_json
workflow_configs = draft.workflow_configurations
template_vars = draft.template_context_variables
else:
published = workflow.released_definition
workflow_def = published.workflow_json
workflow_configs = published.workflow_configurations
template_vars = published.template_context_variables
active_def = draft or workflow.released_definition
2025-09-09 14:37:32 +05:30
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(workflow_def),
2025-09-09 14:37:32 +05:30
"current_definition_id": workflow.current_definition_id,
"template_context_variables": template_vars,
2025-09-09 14:37:32 +05:30
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow_configs,
"version_number": active_def.version_number if active_def else None,
"version_status": active_def.status if active_def else None,
2025-09-09 14:37:32 +05:30
}
@router.get("/{workflow_id}/versions")
async def get_workflow_versions(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> list[WorkflowVersionResponse]:
"""List all versions for a workflow, newest first."""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
versions = await db_client.get_workflow_versions(workflow_id)
return [
WorkflowVersionResponse(
id=v.id,
version_number=v.version_number,
status=v.status,
created_at=v.created_at,
published_at=v.published_at,
workflow_json=mask_workflow_definition(v.workflow_json),
workflow_configurations=v.workflow_configurations,
template_context_variables=v.template_context_variables,
)
for v in versions
if v.version_number is not None
]
@router.post("/{workflow_id}/publish")
async def publish_workflow(
workflow_id: int,
user: UserModel = Depends(get_user),
):
"""Publish the current draft version of a workflow."""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
try:
published = await db_client.publish_workflow_draft(workflow_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
capture_event(
distinct_id=str(user.provider_id),
event=PostHogEvent.WORKFLOW_PUBLISHED,
properties={
"workflow_id": workflow_id,
"version_number": published.version_number,
"organization_id": user.selected_organization_id,
},
)
return {
"id": published.id,
"version_number": published.version_number,
"status": published.status,
"published_at": published.published_at,
}
@router.post("/{workflow_id}/create-draft")
async def create_workflow_draft(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> WorkflowVersionResponse:
"""Create a draft version from the current published version.
If a draft already exists, returns the existing draft.
"""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
draft = await db_client.save_workflow_draft(workflow_id)
return WorkflowVersionResponse(
id=draft.id,
version_number=draft.version_number,
status=draft.status,
created_at=draft.created_at,
published_at=draft.published_at,
workflow_json=mask_workflow_definition(draft.workflow_json),
workflow_configurations=draft.workflow_configurations,
template_context_variables=draft.template_context_variables,
)
2025-09-09 14:37:32 +05:30
@router.get("/summary")
async def get_workflows_summary(
user: UserModel = Depends(get_user),
) -> List[WorkflowSummaryResponse]:
"""Get minimal workflow information (id and name only) for all workflows"""
workflows = await db_client.get_all_workflows(
organization_id=user.selected_organization_id
)
return [
WorkflowSummaryResponse(id=workflow.id, name=workflow.name)
for workflow in workflows
]
@router.put("/{workflow_id}/status")
async def update_workflow_status(
workflow_id: int,
request: UpdateWorkflowStatusRequest,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""
Update the status of a workflow (e.g., archive/unarchive).
Args:
workflow_id: The ID of the workflow to update
request: The status update request
Returns:
The updated workflow
"""
try:
workflow = await db_client.update_workflow_status(
workflow_id=workflow_id,
status=request.status,
organization_id=user.selected_organization_id,
)
run_count = await db_client.get_workflow_run_count(workflow.id)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.released_definition.workflow_json
),
2025-09-09 14:37:32 +05:30
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"total_runs": run_count,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{workflow_id}")
async def update_workflow(
workflow_id: int,
request: UpdateWorkflowRequest,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""
Update an existing workflow.
Args:
workflow_id: The ID of the workflow to update
request: The update request containing the new name and workflow definition
Returns:
The updated workflow
Raises:
HTTPException: If the workflow is not found or if there's a database error
"""
try:
# Restore real API keys where the incoming definition has masked placeholders
workflow_definition = request.workflow_definition
if workflow_definition:
existing_workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if existing_workflow:
# Merge against what the user was editing (draft or published)
existing_draft = await db_client.get_draft_version(workflow_id)
existing_def = (
existing_draft.workflow_json
if existing_draft
else existing_workflow.released_definition.workflow_json
)
workflow_definition = merge_workflow_api_keys(
workflow_definition,
existing_def,
)
# Validate model_overrides: resolve onto global config, then
# run the same validator used by the user-configurations endpoint.
if request.workflow_configurations and request.workflow_configurations.get(
"model_overrides"
):
user_config = await db_client.get_user_configurations(user.id)
try:
effective = resolve_effective_config(
user_config,
request.workflow_configurations["model_overrides"],
)
await UserConfigurationValidator().validate(
effective,
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
2025-09-09 14:37:32 +05:30
workflow = await db_client.update_workflow(
workflow_id=workflow_id,
name=request.name,
workflow_definition=workflow_definition,
2025-09-09 14:37:32 +05:30
template_context_variables=request.template_context_variables,
workflow_configurations=request.workflow_configurations,
organization_id=user.selected_organization_id,
)
# Sync agent triggers if workflow definition was updated
if workflow_definition:
trigger_paths = extract_trigger_paths(workflow_definition)
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
# Return draft content if one exists (save creates a draft)
draft = await db_client.get_draft_version(workflow_id)
if draft:
workflow_def = draft.workflow_json
workflow_configs = draft.workflow_configurations
template_vars = draft.template_context_variables
else:
published = workflow.released_definition
workflow_def = published.workflow_json
workflow_configs = published.workflow_configurations
template_vars = published.template_context_variables
# Include version info from the active definition (draft or published)
active_def = draft or workflow.released_definition
2025-09-09 14:37:32 +05:30
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(workflow_def),
2025-09-09 14:37:32 +05:30
"current_definition_id": workflow.current_definition_id,
"template_context_variables": template_vars,
2025-09-09 14:37:32 +05:30
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow_configs,
"version_number": active_def.version_number if active_def else None,
"version_status": active_def.status if active_def else None,
2025-09-09 14:37:32 +05:30
}
except HTTPException:
raise
2025-09-09 14:37:32 +05:30
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{workflow_id}/duplicate")
async def duplicate_workflow_endpoint(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""Duplicate a workflow including its definition, configuration, recordings, and triggers."""
try:
workflow = await duplicate_workflow(
workflow_id=workflow_id,
organization_id=user.selected_organization_id,
user_id=user.id,
)
capture_event(
distinct_id=str(user.provider_id),
event=PostHogEvent.WORKFLOW_DUPLICATED,
properties={
"workflow_id": workflow.id,
"workflow_name": workflow.name,
"source_workflow_id": workflow_id,
"organization_id": user.selected_organization_id,
},
)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.released_definition.workflow_json
),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error duplicating workflow {workflow_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
2025-09-09 14:37:32 +05:30
@router.post("/{workflow_id}/runs")
async def create_workflow_run(
workflow_id: int,
request: CreateWorkflowRunRequest,
user: UserModel = Depends(get_user),
) -> CreateWorkflowRunResponse:
"""
Create a new workflow run when the user decides to execute the workflow via chat or voice
Args:
workflow_id: The ID of the workflow to run
request: The create workflow run request
user: The user to create the workflow run for
"""
run = await db_client.create_workflow_run(
request.name, workflow_id, request.mode, user.id, use_draft=True
2025-09-09 14:37:32 +05:30
)
return {
"id": run.id,
"workflow_id": run.workflow_id,
"name": run.name,
"mode": run.mode,
"created_at": run.created_at,
"definition_id": run.definition_id,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
}
@router.get("/{workflow_id}/runs/{run_id}")
async def get_workflow_run(
workflow_id: int, run_id: int, user: UserModel = Depends(get_user)
) -> WorkflowRunResponseSchema:
run = await db_client.get_workflow_run(
run_id, organization_id=user.selected_organization_id
)
if not run:
raise HTTPException(status_code=404, detail="Workflow run not found")
return {
"id": run.id,
"workflow_id": run.workflow_id,
"name": run.name,
"mode": run.mode,
"is_completed": run.is_completed,
"transcript_url": run.transcript_url,
"recording_url": run.recording_url,
"cost_info": {
"dograh_token_usage": (
run.cost_info.get("dograh_token_usage")
if run.cost_info and "dograh_token_usage" in run.cost_info
else round(float(run.cost_info.get("total_cost_usd", 0)) * 100, 2)
if run.cost_info and "total_cost_usd" in run.cost_info
else 0
),
"call_duration_seconds": int(
round(run.cost_info.get("call_duration_seconds"))
)
if run.cost_info and run.cost_info.get("call_duration_seconds") is not None
2025-09-09 14:37:32 +05:30
else None,
}
if run.cost_info
else None,
"created_at": run.created_at,
"definition_id": run.definition_id,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
"call_type": run.call_type,
"logs": run.logs,
2026-02-25 18:01:09 +05:30
"annotations": run.annotations,
2025-09-09 14:37:32 +05:30
}
class WorkflowRunsResponse(BaseModel):
runs: List[WorkflowRunResponseSchema]
total_count: int
page: int
limit: int
total_pages: int
applied_filters: Optional[List[dict]] = None
@router.get("/{workflow_id}/runs")
async def get_workflow_runs(
workflow_id: int,
page: int = 1,
limit: int = 50,
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(
None, description="Field to sort by (e.g., 'duration', 'created_at')"
),
sort_order: Optional[str] = Query(
"desc", description="Sort order ('asc' or 'desc')"
),
2025-09-09 14:37:32 +05:30
user: UserModel = Depends(get_user),
) -> WorkflowRunsResponse:
"""
Get workflow runs with optional filtering and sorting.
2025-09-09 14:37:32 +05:30
Filters should be provided as a JSON-encoded array of filter criteria.
Example: [{"attribute": "dateRange", "value": {"from": "2024-01-01", "to": "2024-01-31"}}]
"""
offset = (page - 1) * limit
# Parse filters if provided
filter_criteria = []
if filters:
try:
filter_criteria = json.loads(filters)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid filter format")
# Restrict allowed filter attributes for regular users
allowed_attributes = {
"dateRange",
"dispositionCode",
"duration",
"status",
"tokenUsage",
}
for filter_item in filter_criteria:
attribute = filter_item.get("attribute")
if attribute and attribute not in allowed_attributes:
raise HTTPException(
status_code=403, detail=f"Invalid attribute '{attribute}'"
)
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
workflow_id,
organization_id=user.selected_organization_id,
limit=limit,
offset=offset,
filters=filter_criteria if filter_criteria else None,
sort_by=sort_by,
sort_order=sort_order,
)
2025-09-09 14:37:32 +05:30
total_pages = (total_count + limit - 1) // limit
return WorkflowRunsResponse(
runs=runs,
total_count=total_count,
page=page,
limit=limit,
total_pages=total_pages,
applied_filters=filter_criteria if filter_criteria else None,
)
@router.get("/templates")
async def get_workflow_templates() -> List[WorkflowTemplateResponse]:
"""
Get all available workflow templates.
Returns:
List of workflow templates
"""
template_client = WorkflowTemplateClient()
templates = await template_client.get_all_workflow_templates()
return [
{
"id": template.id,
"template_name": template.template_name,
"template_description": template.template_description,
"template_json": template.template_json,
"created_at": template.created_at,
}
for template in templates
]
@router.post("/templates/duplicate")
async def duplicate_workflow_template(
request: DuplicateTemplateRequest, user: UserModel = Depends(get_user)
) -> WorkflowResponse:
"""
Duplicate a workflow template to create a new workflow for the user.
Args:
request: The duplicate template request
user: The authenticated user
Returns:
The newly created workflow
"""
template_client = WorkflowTemplateClient()
template = await template_client.get_workflow_template(request.template_id)
if not template:
raise HTTPException(
status_code=404,
detail=f"Workflow template with id {request.template_id} not found",
)
# Create a new workflow from the template
# Regenerate trigger UUIDs to avoid conflicts with existing triggers
workflow_def = regenerate_trigger_uuids(template.template_json)
2025-09-09 14:37:32 +05:30
workflow = await db_client.create_workflow(
request.workflow_name,
workflow_def,
2025-09-09 14:37:32 +05:30
user.id,
user.selected_organization_id,
)
# Sync agent triggers if template contains any
if workflow_def:
trigger_paths = extract_trigger_paths(workflow_def)
if trigger_paths:
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
2025-09-09 14:37:32 +05:30
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(workflow_def),
2025-09-09 14:37:32 +05:30
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
# ---------------------------------------------------------------------------
# Ambient Noise Upload
# ---------------------------------------------------------------------------
class AmbientNoiseUploadRequest(BaseModel):
workflow_id: int
filename: str
mime_type: str = "audio/wav"
file_size: int = Field(..., gt=0, le=10_485_760, description="Max 10MB")
class AmbientNoiseUploadResponse(BaseModel):
upload_url: str
storage_key: str
storage_backend: str
@router.post(
"/ambient-noise/upload-url",
response_model=AmbientNoiseUploadResponse,
summary="Get a presigned URL to upload a custom ambient noise audio file",
)
async def get_ambient_noise_upload_url(
request: AmbientNoiseUploadRequest,
user=Depends(get_user),
):
"""Generate a presigned PUT URL for uploading a custom ambient noise file."""
# Verify user owns this workflow
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
sanitized = re.sub(r"[^a-zA-Z0-9._-]", "_", request.filename)
storage_key = (
f"ambient-noise/{user.selected_organization_id}"
f"/{request.workflow_id}/{uuid.uuid4()}_{sanitized}"
)
upload_url = await storage_fs.aget_presigned_put_url(
file_path=storage_key,
expiration=1800,
content_type=request.mime_type,
max_size=request.file_size,
)
if not upload_url:
raise HTTPException(status_code=500, detail="Failed to generate upload URL")
return AmbientNoiseUploadResponse(
upload_url=upload_url,
storage_key=storage_key,
storage_backend=StorageBackend.get_current_backend().value,
)