mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
chore: return formatted transcript url
- Return formatted transcript and recording URL - Harden campaign dispatcher logic
This commit is contained in:
parent
0716582aa7
commit
7810923bca
30 changed files with 525 additions and 136 deletions
|
|
@ -2,7 +2,7 @@ import json
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import func, text
|
from sqlalchemy import func, text, update
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from api.db.base_client import BaseDBClient
|
from api.db.base_client import BaseDBClient
|
||||||
|
|
@ -466,6 +466,63 @@ class CampaignClient(BaseDBClient):
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def increment_campaign_metadata_counter(
|
||||||
|
self, campaign_id: int, key: str
|
||||||
|
) -> int:
|
||||||
|
"""Atomically increment an integer field in campaign orchestrator_metadata."""
|
||||||
|
async with self.async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE campaigns "
|
||||||
|
"SET orchestrator_metadata = ("
|
||||||
|
" COALESCE(orchestrator_metadata::jsonb, '{}'::jsonb) "
|
||||||
|
" || jsonb_build_object("
|
||||||
|
" :key, "
|
||||||
|
" COALESCE((orchestrator_metadata::jsonb ->> :key)::int, 0) + 1"
|
||||||
|
" )"
|
||||||
|
" )::json, "
|
||||||
|
" updated_at = :now "
|
||||||
|
"WHERE id = :campaign_id "
|
||||||
|
"RETURNING (orchestrator_metadata::jsonb ->> :key)::int"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"campaign_id": campaign_id,
|
||||||
|
"key": key,
|
||||||
|
"now": datetime.now(UTC),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
attempt = result.scalar_one()
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
return attempt
|
||||||
|
|
||||||
|
async def reset_campaign_metadata_counter(self, campaign_id: int, key: str) -> None:
|
||||||
|
"""Remove a counter field from campaign orchestrator_metadata."""
|
||||||
|
async with self.async_session() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE campaigns "
|
||||||
|
"SET orchestrator_metadata = ("
|
||||||
|
" COALESCE(orchestrator_metadata::jsonb, '{}'::jsonb) - :key"
|
||||||
|
" )::json, "
|
||||||
|
" updated_at = :now "
|
||||||
|
"WHERE id = :campaign_id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"campaign_id": campaign_id,
|
||||||
|
"key": key,
|
||||||
|
"now": datetime.now(UTC),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
# QueuedRun methods
|
# QueuedRun methods
|
||||||
async def bulk_create_queued_runs(self, queued_runs_data: list[dict]) -> None:
|
async def bulk_create_queued_runs(self, queued_runs_data: list[dict]) -> None:
|
||||||
"""Bulk create queued runs"""
|
"""Bulk create queued runs"""
|
||||||
|
|
@ -501,6 +558,35 @@ class CampaignClient(BaseDBClient):
|
||||||
await session.refresh(queued_run)
|
await session.refresh(queued_run)
|
||||||
return queued_run
|
return queued_run
|
||||||
|
|
||||||
|
async def return_processing_queued_runs_without_workflow(
|
||||||
|
self, queued_run_ids: list[int]
|
||||||
|
) -> int:
|
||||||
|
"""Return claimed queued_runs to queued if no workflow was created for them."""
|
||||||
|
if not queued_run_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
workflow_exists = (
|
||||||
|
select(WorkflowRunModel.id)
|
||||||
|
.where(WorkflowRunModel.queued_run_id == QueuedRunModel.id)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
async with self.async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
update(QueuedRunModel)
|
||||||
|
.where(
|
||||||
|
QueuedRunModel.id.in_(queued_run_ids),
|
||||||
|
QueuedRunModel.state == "processing",
|
||||||
|
~workflow_exists,
|
||||||
|
)
|
||||||
|
.values(state="queued")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
async def count_queued_runs(
|
async def count_queued_runs(
|
||||||
self, campaign_id: int, state: Optional[str] = None
|
self, campaign_id: int, state: Optional[str] = None
|
||||||
) -> int:
|
) -> int:
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,7 @@ class OrganizationUsageClient(BaseDBClient):
|
||||||
"call_duration_seconds": int(round(call_duration)),
|
"call_duration_seconds": int(round(call_duration)),
|
||||||
"recording_url": run.recording_url,
|
"recording_url": run.recording_url,
|
||||||
"transcript_url": run.transcript_url,
|
"transcript_url": run.transcript_url,
|
||||||
|
"public_access_token": run.public_access_token,
|
||||||
"phone_number": phone_number,
|
"phone_number": phone_number,
|
||||||
"caller_number": caller_number,
|
"caller_number": caller_number,
|
||||||
"called_number": called_number,
|
"called_number": called_number,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from api.db.models import UserModel
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user
|
||||||
from api.services.mps_service_key_client import mps_service_key_client
|
from api.services.mps_service_key_client import mps_service_key_client
|
||||||
from api.services.reports import generate_usage_runs_report_csv
|
from api.services.reports import generate_usage_runs_report_csv
|
||||||
|
from api.utils.artifacts import artifact_url
|
||||||
|
|
||||||
router = APIRouter(prefix="/organizations")
|
router = APIRouter(prefix="/organizations")
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ class WorkflowRunUsageResponse(BaseModel):
|
||||||
call_duration_seconds: int
|
call_duration_seconds: int
|
||||||
recording_url: Optional[str] = None
|
recording_url: Optional[str] = None
|
||||||
transcript_url: Optional[str] = None
|
transcript_url: Optional[str] = None
|
||||||
|
public_access_token: Optional[str] = None
|
||||||
phone_number: Optional[str] = Field(
|
phone_number: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
deprecated=True,
|
deprecated=True,
|
||||||
|
|
@ -223,6 +225,15 @@ async def get_usage_history(
|
||||||
|
|
||||||
total_pages = (total_count + limit - 1) // limit
|
total_pages = (total_count + limit - 1) // limit
|
||||||
|
|
||||||
|
for run in runs:
|
||||||
|
public_access_token = run.get("public_access_token")
|
||||||
|
run["transcript_url"] = artifact_url(
|
||||||
|
public_access_token, "transcript", fallback=run.get("transcript_url")
|
||||||
|
)
|
||||||
|
run["recording_url"] = artifact_url(
|
||||||
|
public_access_token, "recording", fallback=run.get("recording_url")
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"runs": runs,
|
"runs": runs,
|
||||||
"total_dograh_tokens": total_tokens,
|
"total_dograh_tokens": total_tokens,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ from api.services.workflow.trigger_paths import (
|
||||||
validate_trigger_paths,
|
validate_trigger_paths,
|
||||||
)
|
)
|
||||||
from api.services.workflow.workflow_graph import WorkflowGraph
|
from api.services.workflow.workflow_graph import WorkflowGraph
|
||||||
|
from api.utils.artifacts import artifact_url
|
||||||
|
|
||||||
router = APIRouter(prefix="/workflow")
|
router = APIRouter(prefix="/workflow")
|
||||||
|
|
||||||
|
|
@ -1113,14 +1114,24 @@ async def get_workflow_run(
|
||||||
)
|
)
|
||||||
if not run:
|
if not run:
|
||||||
raise HTTPException(status_code=404, detail="Workflow run not found")
|
raise HTTPException(status_code=404, detail="Workflow run not found")
|
||||||
|
|
||||||
|
public_access_token = run.public_access_token
|
||||||
|
if (run.transcript_url or run.recording_url) and not public_access_token:
|
||||||
|
public_access_token = await db_client.ensure_public_access_token(run.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": run.id,
|
"id": run.id,
|
||||||
"workflow_id": run.workflow_id,
|
"workflow_id": run.workflow_id,
|
||||||
"name": run.name,
|
"name": run.name,
|
||||||
"mode": run.mode,
|
"mode": run.mode,
|
||||||
"is_completed": run.is_completed,
|
"is_completed": run.is_completed,
|
||||||
"transcript_url": run.transcript_url,
|
"transcript_url": artifact_url(
|
||||||
"recording_url": run.recording_url,
|
public_access_token, "transcript", fallback=run.transcript_url
|
||||||
|
),
|
||||||
|
"recording_url": artifact_url(
|
||||||
|
public_access_token, "recording", fallback=run.recording_url
|
||||||
|
),
|
||||||
|
"public_access_token": public_access_token,
|
||||||
"cost_info": {
|
"cost_info": {
|
||||||
"dograh_token_usage": (
|
"dograh_token_usage": (
|
||||||
run.cost_info.get("dograh_token_usage")
|
run.cost_info.get("dograh_token_usage")
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class WorkflowRunResponseSchema(BaseModel):
|
||||||
is_completed: bool
|
is_completed: bool
|
||||||
transcript_url: str | None
|
transcript_url: str | None
|
||||||
recording_url: str | None
|
recording_url: str | None
|
||||||
|
public_access_token: str | None = None
|
||||||
cost_info: Dict[str, Any] | None
|
cost_info: Dict[str, Any] | None
|
||||||
definition_id: int | None # This is for backward compatibility
|
definition_id: int | None # This is for backward compatibility
|
||||||
initial_context: dict | None = None
|
initial_context: dict | None = None
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ class CampaignCallDispatcher:
|
||||||
logger.warning(f"Failed to initialize from_number pool: {e}")
|
logger.warning(f"Failed to initialize from_number pool: {e}")
|
||||||
|
|
||||||
processed_count = 0
|
processed_count = 0
|
||||||
|
processed_run_ids: set[int] = set()
|
||||||
for i, queued_run in enumerate(queued_runs):
|
for i, queued_run in enumerate(queued_runs):
|
||||||
try:
|
try:
|
||||||
# Apply rate limiting, i.e lets not initiate more than rate_limit_per_second
|
# Apply rate limiting, i.e lets not initiate more than rate_limit_per_second
|
||||||
|
|
@ -133,28 +134,48 @@ class CampaignCallDispatcher:
|
||||||
)
|
)
|
||||||
|
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
|
processed_run_ids.add(queued_run.id)
|
||||||
|
|
||||||
# Update campaign processed count
|
# Update campaign processed count
|
||||||
await db_client.update_campaign(
|
await db_client.update_campaign(
|
||||||
campaign_id=campaign_id, processed_rows=campaign.processed_rows + 1
|
campaign_id=campaign_id, processed_rows=campaign.processed_rows + 1
|
||||||
)
|
)
|
||||||
|
|
||||||
except (ConcurrentSlotAcquisitionError, PhoneNumberPoolExhaustedError):
|
except asyncio.CancelledError:
|
||||||
# Revert all unprocessed runs (current and remaining) back to queued
|
logger.warning(
|
||||||
# so they can be picked up again when campaign is resumed
|
f"Campaign {campaign_id} batch cancelled; returning claimed "
|
||||||
for unprocessed_run in queued_runs[i:]:
|
"queued runs that were not dispatched"
|
||||||
try:
|
)
|
||||||
await db_client.update_queued_run(
|
await self._return_unprocessed_claims(
|
||||||
queued_run_id=unprocessed_run.id,
|
queued_runs, processed_run_ids, reason="task_cancelled"
|
||||||
state="queued",
|
)
|
||||||
)
|
raise
|
||||||
logger.info(
|
|
||||||
f"Reverted queued run {unprocessed_run.id} back to queued state"
|
except PhoneNumberPoolExhaustedError as e:
|
||||||
)
|
logger.warning(
|
||||||
except Exception as revert_error:
|
f"Phone number pool exhausted for campaign {campaign_id}; "
|
||||||
logger.error(
|
"returning claimed queued runs that were not dispatched: "
|
||||||
f"Failed to revert queued run {unprocessed_run.id}: {revert_error}"
|
f"{e}"
|
||||||
)
|
)
|
||||||
|
await self._return_unprocessed_claims(
|
||||||
|
queued_runs,
|
||||||
|
processed_run_ids,
|
||||||
|
reason="phone_number_pool_exhausted",
|
||||||
|
)
|
||||||
|
# Re-raise to propagate to process_campaign_batch
|
||||||
|
raise
|
||||||
|
|
||||||
|
except ConcurrentSlotAcquisitionError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Concurrent slot acquisition failed for campaign {campaign_id}; "
|
||||||
|
"returning claimed queued runs that were not dispatched: "
|
||||||
|
f"{e}"
|
||||||
|
)
|
||||||
|
await self._return_unprocessed_claims(
|
||||||
|
queued_runs,
|
||||||
|
processed_run_ids,
|
||||||
|
reason="concurrent_slot_acquisition_failed",
|
||||||
|
)
|
||||||
# Re-raise to propagate to process_campaign_batch
|
# Re-raise to propagate to process_campaign_batch
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -178,6 +199,38 @@ class CampaignCallDispatcher:
|
||||||
|
|
||||||
return processed_count
|
return processed_count
|
||||||
|
|
||||||
|
async def _return_unprocessed_claims(
|
||||||
|
self,
|
||||||
|
queued_runs: list[QueuedRunModel],
|
||||||
|
processed_run_ids: set[int],
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
queued_run_ids = [
|
||||||
|
queued_run.id
|
||||||
|
for queued_run in queued_runs
|
||||||
|
if queued_run.id not in processed_run_ids
|
||||||
|
]
|
||||||
|
if not queued_run_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
returned_count = (
|
||||||
|
await db_client.return_processing_queued_runs_without_workflow(
|
||||||
|
queued_run_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Returned {returned_count}/{len(queued_run_ids)} claimed queued runs "
|
||||||
|
f"back to queued state; reason={reason}; "
|
||||||
|
f"queued_run_ids={queued_run_ids}"
|
||||||
|
)
|
||||||
|
except Exception as revert_error:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to return claimed queued runs; reason={reason}; "
|
||||||
|
f"queued_run_ids={queued_run_ids}; error={revert_error}"
|
||||||
|
)
|
||||||
|
|
||||||
async def dispatch_call(
|
async def dispatch_call(
|
||||||
self, queued_run: QueuedRunModel, campaign: any, slot_id: str
|
self, queued_run: QueuedRunModel, campaign: any, slot_id: str
|
||||||
) -> Optional[WorkflowRunModel]:
|
) -> Optional[WorkflowRunModel]:
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,8 @@ import io
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from api.constants import BACKEND_API_ENDPOINT
|
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
|
from api.utils.artifacts import artifact_url
|
||||||
|
|
||||||
def _artifact_url(token: str | None, artifact: str) -> str:
|
|
||||||
if not token:
|
|
||||||
return ""
|
|
||||||
return f"{BACKEND_API_ENDPOINT}/api/v1/public/download/workflow/{token}/{artifact}"
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_extracted_variable_keys(runs: List[Any]) -> list[str]:
|
def _collect_extracted_variable_keys(runs: List[Any]) -> list[str]:
|
||||||
|
|
@ -83,8 +77,8 @@ def build_run_report_csv(runs: List[Any]) -> io.StringIO:
|
||||||
|
|
||||||
post_values = [
|
post_values = [
|
||||||
call_tags,
|
call_tags,
|
||||||
_artifact_url(run.public_access_token, "transcript"),
|
artifact_url(run.public_access_token, "transcript") or "",
|
||||||
_artifact_url(run.public_access_token, "recording"),
|
artifact_url(run.public_access_token, "recording") or "",
|
||||||
]
|
]
|
||||||
|
|
||||||
writer.writerow(pre_values + extracted_values + post_values)
|
writer.writerow(pre_values + extracted_values + post_values)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ from api.services.campaign.errors import (
|
||||||
)
|
)
|
||||||
from api.services.campaign.source_sync_factory import get_sync_service
|
from api.services.campaign.source_sync_factory import get_sync_service
|
||||||
|
|
||||||
|
PHONE_NUMBER_POOL_EXHAUSTED_COUNTER_KEY = "phone_number_pool_exhausted_attempts"
|
||||||
|
MAX_PHONE_NUMBER_POOL_EXHAUSTED_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None:
|
async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -118,6 +121,12 @@ async def process_campaign_batch(
|
||||||
campaign_id=campaign_id, batch_size=batch_size
|
campaign_id=campaign_id, batch_size=batch_size
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if processed_count > 0:
|
||||||
|
await db_client.reset_campaign_metadata_counter(
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
key=PHONE_NUMBER_POOL_EXHAUSTED_COUNTER_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
# Publish batch completed event - orchestrator will handle next batch scheduling
|
# Publish batch completed event - orchestrator will handle next batch scheduling
|
||||||
publisher = await get_campaign_event_publisher()
|
publisher = await get_campaign_event_publisher()
|
||||||
await publisher.publish_batch_completed(
|
await publisher.publish_batch_completed(
|
||||||
|
|
@ -157,9 +166,43 @@ async def process_campaign_batch(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except PhoneNumberPoolExhaustedError as e:
|
except PhoneNumberPoolExhaustedError as e:
|
||||||
logger.warning(f"Phone number pool exhausted for campaign {campaign_id}: {e}")
|
attempt = await db_client.increment_campaign_metadata_counter(
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
key=PHONE_NUMBER_POOL_EXHAUSTED_COUNTER_KEY,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"Phone number pool exhausted for campaign {campaign_id}: {e}; "
|
||||||
|
f"attempt={attempt}/{MAX_PHONE_NUMBER_POOL_EXHAUSTED_ATTEMPTS}"
|
||||||
|
)
|
||||||
|
|
||||||
publisher = await get_campaign_event_publisher()
|
publisher = await get_campaign_event_publisher()
|
||||||
|
|
||||||
|
if attempt < MAX_PHONE_NUMBER_POOL_EXHAUSTED_ATTEMPTS:
|
||||||
|
await db_client.append_campaign_log(
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
level="warning",
|
||||||
|
event="phone_number_pool_exhausted_retry",
|
||||||
|
message=(
|
||||||
|
f"Phone number pool exhausted for org {e.organization_id}: "
|
||||||
|
"no free from_number available to dispatch outbound calls; "
|
||||||
|
f"retry attempt {attempt}/"
|
||||||
|
f"{MAX_PHONE_NUMBER_POOL_EXHAUSTED_ATTEMPTS}"
|
||||||
|
),
|
||||||
|
details={
|
||||||
|
"error": str(e),
|
||||||
|
"organization_id": e.organization_id,
|
||||||
|
"attempt": attempt,
|
||||||
|
"max_attempts": MAX_PHONE_NUMBER_POOL_EXHAUSTED_ATTEMPTS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await publisher.publish_batch_completed(
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
processed_count=0,
|
||||||
|
failed_count=0,
|
||||||
|
batch_size=batch_size,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await publisher.publish_batch_failed(
|
await publisher.publish_batch_failed(
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
error=f"Phone number pool exhausted: {e}",
|
error=f"Phone number pool exhausted: {e}",
|
||||||
|
|
@ -172,12 +215,15 @@ async def process_campaign_batch(
|
||||||
level="error",
|
level="error",
|
||||||
event="phone_number_pool_exhausted",
|
event="phone_number_pool_exhausted",
|
||||||
message=(
|
message=(
|
||||||
f"Phone number pool exhausted for org {e.organization_id}: "
|
f"Phone number pool exhausted for org {e.organization_id} after "
|
||||||
"no free from_number available to dispatch outbound calls"
|
f"{attempt} consecutive attempts: no free from_number available "
|
||||||
|
"to dispatch outbound calls"
|
||||||
),
|
),
|
||||||
details={
|
details={
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"organization_id": e.organization_id,
|
"organization_id": e.organization_id,
|
||||||
|
"attempt": attempt,
|
||||||
|
"max_attempts": MAX_PHONE_NUMBER_POOL_EXHAUSTED_ATTEMPTS,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -681,6 +681,54 @@ class TestProcessBatchConcurrency:
|
||||||
assert states.get("processing", 0) == 0
|
assert states.get("processing", 0) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessBatchCancellation:
|
||||||
|
"""Cancellation cleanup for claimed queued runs."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancelled_batch_returns_claimed_runs_without_workflows(self):
|
||||||
|
dispatcher = CampaignCallDispatcher()
|
||||||
|
campaign = MagicMock()
|
||||||
|
campaign.id = 42
|
||||||
|
campaign.state = "running"
|
||||||
|
campaign.organization_id = 7
|
||||||
|
campaign.rate_limit_per_second = 1
|
||||||
|
campaign.telephony_configuration_id = 170
|
||||||
|
|
||||||
|
queued_runs = [MagicMock(id=101), MagicMock(id=102), MagicMock(id=103)]
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.from_numbers = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"api.services.campaign.campaign_call_dispatcher.db_client"
|
||||||
|
) as mock_db,
|
||||||
|
patch.object(
|
||||||
|
dispatcher,
|
||||||
|
"get_provider_for_campaign",
|
||||||
|
AsyncMock(return_value=provider),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
dispatcher,
|
||||||
|
"apply_rate_limit",
|
||||||
|
AsyncMock(side_effect=asyncio.CancelledError),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_db.get_campaign_by_id = AsyncMock(return_value=campaign)
|
||||||
|
mock_db.claim_queued_runs_for_processing = AsyncMock(
|
||||||
|
return_value=queued_runs
|
||||||
|
)
|
||||||
|
mock_db.return_processing_queued_runs_without_workflow = AsyncMock(
|
||||||
|
return_value=3
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await dispatcher.process_batch(campaign_id=42, batch_size=3)
|
||||||
|
|
||||||
|
mock_db.return_processing_queued_runs_without_workflow.assert_awaited_once_with(
|
||||||
|
[101, 102, 103]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestProcessBatchEdgeCases:
|
class TestProcessBatchEdgeCases:
|
||||||
"""Edge case tests for process_batch."""
|
"""Edge case tests for process_batch."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,9 @@ class TestProcessCampaignBatchFailureLogs:
|
||||||
``batch_failed`` entry."""
|
``batch_failed`` entry."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_phone_number_pool_exhausted_logs_specific_event(self):
|
async def test_phone_number_pool_exhausted_retries_before_final_failure(self):
|
||||||
"""When PhoneNumberPoolExhaustedError propagates from process_batch,
|
"""The first two consecutive pool exhaustion attempts keep the
|
||||||
the campaign log entry should use event='phone_number_pool_exhausted'
|
campaign running and schedule another batch."""
|
||||||
with a clear message — not the generic 'batch_failed' bucket."""
|
|
||||||
with (
|
with (
|
||||||
patch("api.tasks.campaign_tasks.campaign_call_dispatcher") as mock_disp,
|
patch("api.tasks.campaign_tasks.campaign_call_dispatcher") as mock_disp,
|
||||||
patch("api.tasks.campaign_tasks.db_client") as mock_db,
|
patch("api.tasks.campaign_tasks.db_client") as mock_db,
|
||||||
|
|
@ -37,6 +36,46 @@ class TestProcessCampaignBatchFailureLogs:
|
||||||
mock_disp.process_batch = AsyncMock(
|
mock_disp.process_batch = AsyncMock(
|
||||||
side_effect=PhoneNumberPoolExhaustedError(organization_id=7)
|
side_effect=PhoneNumberPoolExhaustedError(organization_id=7)
|
||||||
)
|
)
|
||||||
|
mock_db.increment_campaign_metadata_counter = AsyncMock(return_value=2)
|
||||||
|
mock_db.update_campaign = AsyncMock()
|
||||||
|
mock_db.append_campaign_log = AsyncMock()
|
||||||
|
mock_pub = AsyncMock()
|
||||||
|
mock_get_pub.return_value = mock_pub
|
||||||
|
|
||||||
|
await process_campaign_batch({}, campaign_id=42)
|
||||||
|
|
||||||
|
mock_db.update_campaign.assert_not_awaited()
|
||||||
|
mock_pub.publish_batch_failed.assert_not_awaited()
|
||||||
|
mock_pub.publish_batch_completed.assert_awaited_once_with(
|
||||||
|
campaign_id=42,
|
||||||
|
processed_count=0,
|
||||||
|
failed_count=0,
|
||||||
|
batch_size=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_db.append_campaign_log.assert_called_once()
|
||||||
|
kwargs = mock_db.append_campaign_log.call_args.kwargs
|
||||||
|
assert kwargs["campaign_id"] == 42
|
||||||
|
assert kwargs["event"] == "phone_number_pool_exhausted_retry"
|
||||||
|
assert kwargs["level"] == "warning"
|
||||||
|
assert kwargs["details"]["organization_id"] == 7
|
||||||
|
assert kwargs["details"]["attempt"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_phone_number_pool_exhausted_fails_on_third_attempt(self):
|
||||||
|
"""The third consecutive pool exhaustion attempt marks the campaign
|
||||||
|
failed with a specific operator-facing log entry."""
|
||||||
|
with (
|
||||||
|
patch("api.tasks.campaign_tasks.campaign_call_dispatcher") as mock_disp,
|
||||||
|
patch("api.tasks.campaign_tasks.db_client") as mock_db,
|
||||||
|
patch(
|
||||||
|
"api.tasks.campaign_tasks.get_campaign_event_publisher"
|
||||||
|
) as mock_get_pub,
|
||||||
|
):
|
||||||
|
mock_disp.process_batch = AsyncMock(
|
||||||
|
side_effect=PhoneNumberPoolExhaustedError(organization_id=7)
|
||||||
|
)
|
||||||
|
mock_db.increment_campaign_metadata_counter = AsyncMock(return_value=3)
|
||||||
mock_db.update_campaign = AsyncMock()
|
mock_db.update_campaign = AsyncMock()
|
||||||
mock_db.append_campaign_log = AsyncMock()
|
mock_db.append_campaign_log = AsyncMock()
|
||||||
mock_pub = AsyncMock()
|
mock_pub = AsyncMock()
|
||||||
|
|
@ -48,6 +87,7 @@ class TestProcessCampaignBatchFailureLogs:
|
||||||
mock_db.update_campaign.assert_called_once_with(
|
mock_db.update_campaign.assert_called_once_with(
|
||||||
campaign_id=42, state="failed"
|
campaign_id=42, state="failed"
|
||||||
)
|
)
|
||||||
|
mock_pub.publish_batch_failed.assert_awaited_once()
|
||||||
|
|
||||||
mock_db.append_campaign_log.assert_called_once()
|
mock_db.append_campaign_log.assert_called_once()
|
||||||
kwargs = mock_db.append_campaign_log.call_args.kwargs
|
kwargs = mock_db.append_campaign_log.call_args.kwargs
|
||||||
|
|
@ -56,6 +96,7 @@ class TestProcessCampaignBatchFailureLogs:
|
||||||
assert kwargs["level"] == "error"
|
assert kwargs["level"] == "error"
|
||||||
assert "phone number" in kwargs["message"].lower()
|
assert "phone number" in kwargs["message"].lower()
|
||||||
assert kwargs["details"]["organization_id"] == 7
|
assert kwargs["details"]["organization_id"] == 7
|
||||||
|
assert kwargs["details"]["attempt"] == 3
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_concurrent_slot_timeout_still_logs_specific_event(self):
|
async def test_concurrent_slot_timeout_still_logs_specific_event(self):
|
||||||
|
|
|
||||||
11
api/utils/artifacts.py
Normal file
11
api/utils/artifacts.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Helpers for workflow run artifact access."""
|
||||||
|
|
||||||
|
from api.constants import BACKEND_API_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_url(
|
||||||
|
token: str | None, artifact: str, fallback: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
if not token:
|
||||||
|
return fallback
|
||||||
|
return f"{BACKEND_API_ENDPOINT}/api/v1/public/download/workflow/{token}/{artifact}"
|
||||||
|
|
@ -4,4 +4,4 @@ description: "Retrieve a single workflow run by ID"
|
||||||
openapi: "GET /api/v1/workflow/{workflow_id}/runs/{run_id}"
|
openapi: "GET /api/v1/workflow/{workflow_id}/runs/{run_id}"
|
||||||
---
|
---
|
||||||
|
|
||||||
Returns the full run record including status, transcript, recording URL, gathered context, and usage/cost info. Use `recording_url` and `transcript_url` to download artifacts, or use the [Download endpoint](/api-reference/calls/download) for time-limited public URLs.
|
Returns the full run record including status, transcript, recording URL, gathered context, and usage/cost info. Use `recording_url` and `transcript_url` to download artifacts, or use the [Download endpoint](/api-reference/runs/download) for time-limited public URLs.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
---
|
|
||||||
title: "Retrieve Call Details"
|
|
||||||
description: "Get the details, transcript, and recording for a call"
|
|
||||||
openapi: "GET /api/v1/workflow/{workflow_id}/runs/{run_id}"
|
|
||||||
---
|
|
||||||
|
|
||||||
Returns the full run record including call status, duration, transcript URL, recording URL, gathered context, and usage/cost info.
|
|
||||||
|
|
||||||
Use the `recording_url` and `transcript_url` directly, or use the [Download endpoint](/api-reference/calls/download) to generate time-limited public URLs for sharing.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
---
|
|
||||||
title: "Inbound Call Webhook"
|
|
||||||
description: "Webhook endpoint that routes inbound calls to a specific agent"
|
|
||||||
openapi: "POST /api/v1/telephony/inbound/{workflow_id}"
|
|
||||||
---
|
|
||||||
|
|
||||||
Configure this URL in your telephony provider's dashboard (Twilio, Vonage, etc.) to route inbound calls to a specific agent. The `workflow_id` determines which agent handles the call.
|
|
||||||
|
|
||||||
See [Inbound Calls](/integrations/telephony/inbound) for full setup instructions per provider.
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
title: "Get Campaign Runs"
|
title: "Get Campaign Runs"
|
||||||
description: "Retrieve individual call records for each contact in a campaign"
|
description: "Retrieve individual run records for each contact in a campaign"
|
||||||
openapi: "GET /api/v1/campaign/{campaign_id}/runs"
|
openapi: "GET /api/v1/campaign/{campaign_id}/runs"
|
||||||
---
|
---
|
||||||
|
|
||||||
Returns the individual call records for each contact in the campaign. Each record includes the same fields as a [workflow run](/api-reference/calls#retrieve-call-details), including call status, duration, transcript, and recording URL.
|
Returns the individual run records for each contact in the campaign. Each record includes the same fields as [agent run details](/api-reference/runs/get-run), including run status, duration, transcript, and recording URL.
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,24 +1,24 @@
|
||||||
---
|
---
|
||||||
title: "Overview"
|
title: "Overview"
|
||||||
description: "Initiate outbound calls and trigger agents via the API"
|
description: "Create and inspect agent runs via the API"
|
||||||
---
|
---
|
||||||
|
|
||||||
| Method | Endpoint | Quick Link |
|
| Method | Endpoint | Quick Link |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/public/agent/{uuid}` | [Trigger an outbound call by API Trigger node](/api-reference/calls/trigger) |
|
| `POST` | `/public/agent/{uuid}` | [Trigger an outbound agent run by API Trigger node](/api-reference/runs/trigger) |
|
||||||
| `POST` | `/public/agent/workflow/{workflow_uuid}` | [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow) |
|
| `POST` | `/public/agent/workflow/{workflow_uuid}` | [Trigger an outbound agent run by Agent UUID](/api-reference/runs/trigger-workflow) |
|
||||||
| `GET` | `/workflow/{workflow_id}/runs/{run_id}` | [Retrieve call details](/api-reference/calls/get-run) |
|
| `GET` | `/workflow/{workflow_id}/runs/{run_id}` | [Retrieve agent run details](/api-reference/runs/get-run) |
|
||||||
| `GET` | `/public/download/workflow/{token}/{artifact_type}` | [Download recordings and transcripts](/api-reference/calls/download) |
|
| `GET` | `/public/download/workflow/{token}/{artifact_type}` | [Download recordings and transcripts](/api-reference/runs/download) |
|
||||||
| `POST` | `/telephony/inbound/{workflow_id}` | [Inbound call webhook](/api-reference/calls/inbound) |
|
| `POST` | `/telephony/inbound/{workflow_id}` | [Inbound run webhook](/api-reference/runs/inbound) |
|
||||||
|
|
||||||
## Choose the right public call route
|
## Choose the right public run route
|
||||||
|
|
||||||
Dograh exposes two public outbound call route families. They are **not**
|
Dograh exposes two public outbound agent run route families. They are **not**
|
||||||
interchangeable, even though both path parameters look like UUIDs.
|
interchangeable, even though both path parameters look like UUIDs.
|
||||||
|
|
||||||
| Use this when | Production route | Test route | Identifier you pass |
|
| Use this when | Production route | Test route | Identifier you pass |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| You added an **[API Trigger node](/voice-agent/api-trigger)** to the workflow and want to call that trigger | `/public/agent/{uuid}` | `/public/agent/test/{uuid}` | The trigger UUID (`trigger_path`) from the API Trigger node |
|
| You added an **[API Trigger node](/voice-agent/api-trigger)** to the workflow and want to execute that trigger | `/public/agent/{uuid}` | `/public/agent/test/{uuid}` | The trigger UUID (`trigger_path`) from the API Trigger node |
|
||||||
| You want to execute the workflow by its stable **Agent UUID** instead of a trigger node | `/public/agent/workflow/{workflow_uuid}` | `/public/agent/test/workflow/{workflow_uuid}` | The workflow UUID from the agent's **[Agent UUID](/configurations/agent-uuid)** field |
|
| You want to execute the workflow by its stable **Agent UUID** instead of a trigger node | `/public/agent/workflow/{workflow_uuid}` | `/public/agent/test/workflow/{workflow_uuid}` | The workflow UUID from the agent's **[Agent UUID](/configurations/agent-uuid)** field |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|
@ -34,11 +34,11 @@ Once Dograh resolves the target agent, both route families behave the same:
|
||||||
- They validate the same `X-API-Key` organization boundary
|
- They validate the same `X-API-Key` organization boundary
|
||||||
- They use the same telephony configuration selection rules
|
- They use the same telephony configuration selection rules
|
||||||
|
|
||||||
If you specifically need the API Trigger route, see [Trigger an outbound call by API Trigger node](/api-reference/calls/trigger). To execute by workflow UUID, see [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow).
|
If you specifically need the API Trigger route, see [Trigger an outbound agent run by API Trigger node](/api-reference/runs/trigger). To execute by Agent UUID, see [Trigger an outbound agent run by Agent UUID](/api-reference/runs/trigger-workflow).
|
||||||
|
|
||||||
## Using initial context
|
## Using initial context
|
||||||
|
|
||||||
`initial_context` passes runtime data into the agent at call time. Values are available as template variables in your agent's prompt using double-brace syntax.
|
`initial_context` passes runtime data into the agent at run time. Values are available as template variables in your agent's prompt using double-brace syntax.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -49,13 +49,13 @@ If you specifically need the API Trigger route, see [Trigger an outbound call by
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Your agent prompt can then reference `{{customer_name}}` and `{{appointment_date}}` and they will be substituted when the call starts.
|
Your agent prompt can then reference `{{customer_name}}` and `{{appointment_date}}` and they will be substituted when the run starts.
|
||||||
|
|
||||||
## Run status values
|
## Run status values
|
||||||
|
|
||||||
| Status | Description |
|
| Status | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pending` | Call queued but not yet connected |
|
| `pending` | Run queued but not yet connected |
|
||||||
| `in_progress` | Call is live |
|
| `in_progress` | Run is live |
|
||||||
| `completed` | Call ended normally |
|
| `completed` | Run ended normally |
|
||||||
| `failed` | Call failed before or during execution |
|
| `failed` | Run failed before or during execution |
|
||||||
9
docs/api-reference/runs/get-run.mdx
Normal file
9
docs/api-reference/runs/get-run.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
title: "Retrieve Agent Run Details"
|
||||||
|
description: "Get the details, transcript, and recording for an agent run"
|
||||||
|
openapi: "GET /api/v1/workflow/{workflow_id}/runs/{run_id}"
|
||||||
|
---
|
||||||
|
|
||||||
|
Returns the full run record including run status, duration, transcript URL, recording URL, gathered context, and usage/cost info.
|
||||||
|
|
||||||
|
Use the `recording_url` and `transcript_url` directly, or use the [Download endpoint](/api-reference/runs/download) to generate time-limited public URLs for sharing.
|
||||||
9
docs/api-reference/runs/inbound.mdx
Normal file
9
docs/api-reference/runs/inbound.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
title: "Inbound Run Webhook"
|
||||||
|
description: "Webhook endpoint that starts agent runs from inbound calls"
|
||||||
|
openapi: "POST /api/v1/telephony/inbound/{workflow_id}"
|
||||||
|
---
|
||||||
|
|
||||||
|
Configure this URL in your telephony provider's dashboard (Twilio, Vonage, etc.) to start agent runs from inbound calls to a specific agent. The `workflow_id` determines which agent handles the call.
|
||||||
|
|
||||||
|
See [Inbound calls](/integrations/telephony/inbound) for full setup instructions per provider.
|
||||||
|
|
@ -8,4 +8,4 @@ Returns a paginated list of runs across all agents in your organization, includi
|
||||||
|
|
||||||
Use `start_date` and `end_date` (ISO 8601) to scope the window, and `page` / `limit` to paginate. Pass `filters` as a JSON-encoded string to narrow results further.
|
Use `start_date` and `end_date` (ISO 8601) to scope the window, and `page` / `limit` to paginate. Pass `filters` as a JSON-encoded string to narrow results further.
|
||||||
|
|
||||||
To fetch the full transcript or recording for a specific run, use [Retrieve Call Details](/api-reference/calls/get-run).
|
To fetch the full transcript or recording for a specific run, use [Retrieve Agent Run Details](/api-reference/runs/get-run).
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
---
|
---
|
||||||
title: "Trigger an Outbound Call by Workflow UUID"
|
title: "Trigger an Outbound Agent Run by Agent UUID"
|
||||||
description: "Initiate an outbound call using a workflow's stable Agent UUID"
|
description: "Start an outbound agent run using a workflow's stable Agent UUID"
|
||||||
openapi: "POST /api/v1/public/agent/workflow/{workflow_uuid}"
|
openapi: "POST /api/v1/public/agent/workflow/{workflow_uuid}"
|
||||||
---
|
---
|
||||||
|
|
||||||
Use this endpoint when you want to execute a workflow directly by its stable Agent UUID instead of through an API Trigger node.
|
Use this endpoint when you want to start an agent run directly by its stable Agent UUID instead of through an API Trigger node.
|
||||||
|
|
||||||
The `workflow_uuid` is the workflow's Agent UUID. It is different from an API Trigger node's `trigger_path`.
|
The `workflow_uuid` is the workflow's Agent UUID. It is different from an API Trigger node's `trigger_path`.
|
||||||
|
|
||||||
To find and copy the Agent UUID in the UI, see [Agent UUID](/configurations/agent-uuid).
|
To find and copy the Agent UUID in the UI, see [Agent UUID](/configurations/agent-uuid).
|
||||||
|
|
||||||
Use `workflow_run_id` from the response to later [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts.
|
Use `workflow_run_id` from the response to later [retrieve run details](/api-reference/runs/get-run), recordings, and transcripts.
|
||||||
|
|
||||||
Pass `initial_context` to inject runtime data as template variables into the agent's prompt. See [Using initial context](/api-reference/calls#using-initial-context).
|
Pass `initial_context` to inject runtime data as template variables into the agent's prompt. See [Using initial context](/api-reference/runs#using-initial-context).
|
||||||
|
|
||||||
Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local).
|
Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local).
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
This route expects a workflow UUID. Do not pass an API Trigger node UUID here. If you want to execute via an API Trigger node, use [Trigger an outbound call](/api-reference/calls/trigger) instead.
|
This route expects a workflow UUID. Do not pass an API Trigger node UUID here. If you want to execute via an API Trigger node, use [Trigger an outbound agent run](/api-reference/runs/trigger) instead.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
---
|
---
|
||||||
title: "Trigger an Outbound Call by API Trigger Node"
|
title: "Trigger an Outbound Agent Run by API Trigger Node"
|
||||||
description: "Initiate an outbound call using an API Trigger node UUID"
|
description: "Start an outbound agent run using an API Trigger node UUID"
|
||||||
openapi: "POST /api/v1/public/agent/{uuid}"
|
openapi: "POST /api/v1/public/agent/{uuid}"
|
||||||
---
|
---
|
||||||
|
|
||||||
Use this endpoint when you want to execute a workflow through an [API Trigger node](/voice-agent/api-trigger).
|
Use this endpoint when you want to start an agent run through an [API Trigger node](/voice-agent/api-trigger).
|
||||||
|
|
||||||
The `uuid` comes from the API Trigger node in your agent. Add the node to your workflow and copy its auto-generated `trigger_path`.
|
The `uuid` comes from the API Trigger node in your agent. Add the node to your workflow and copy its auto-generated `trigger_path`.
|
||||||
|
|
||||||
Use `workflow_run_id` from the response to later [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts.
|
Use `workflow_run_id` from the response to later [retrieve run details](/api-reference/runs/get-run), recordings, and transcripts.
|
||||||
|
|
||||||
Pass `initial_context` to inject runtime data as template variables into the agent's prompt. See [Using initial context](/api-reference/calls#using-initial-context).
|
Pass `initial_context` to inject runtime data as template variables into the agent's prompt. See [Using initial context](/api-reference/runs#using-initial-context).
|
||||||
|
|
||||||
Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local).
|
Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local).
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
This route expects an API Trigger node UUID (`trigger_path`). Do not pass a workflow UUID here. If you want to execute by workflow UUID, use [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow) instead.
|
This route expects an API Trigger node UUID (`trigger_path`). Do not pass a workflow UUID here. If you want to execute by Agent UUID, use [Trigger an outbound agent run by Agent UUID](/api-reference/runs/trigger-workflow) instead.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|
@ -205,15 +205,15 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Calls",
|
"group": "Runs",
|
||||||
"pages": [
|
"pages": [
|
||||||
"api-reference/calls",
|
"api-reference/runs",
|
||||||
"api-reference/calls/trigger",
|
"api-reference/runs/trigger",
|
||||||
"api-reference/calls/trigger-workflow",
|
"api-reference/runs/trigger-workflow",
|
||||||
"api-reference/calls/get-run",
|
"api-reference/runs/get-run",
|
||||||
"api-reference/calls/list-runs",
|
"api-reference/runs/list-runs",
|
||||||
"api-reference/calls/download",
|
"api-reference/runs/download",
|
||||||
"api-reference/calls/inbound"
|
"api-reference/runs/inbound"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ Use the test URL when you want to verify draft changes before publishing.
|
||||||
|
|
||||||
### Response
|
### Response
|
||||||
|
|
||||||
A successful request returns a `workflow_run_id` that you can use to [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts.
|
A successful request returns a `workflow_run_id` that you can use to [retrieve run details](/api-reference/runs/get-run), recordings, and transcripts.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -136,5 +136,5 @@ By default, calls are placed through your organization's default outbound [telep
|
||||||
The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local). The configuration must belong to the same organization as the API Trigger; otherwise the request returns `404`.
|
The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local). The configuration must belong to the same organization as the API Trigger; otherwise the request returns `404`.
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
For full endpoint details including all parameters and response fields, see the [API reference](/api-reference/calls/trigger).
|
For full endpoint details including all parameters and response fields, see the [API reference](/api-reference/runs/trigger).
|
||||||
</Note>
|
</Note>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: dograh-openapi-T200ed.json
|
# filename: dograh-openapi-hxItwp.json
|
||||||
# timestamp: 2026-05-25T12:42:12+00:00
|
# timestamp: 2026-05-26T07:50:48+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -656,7 +656,7 @@ function RenderWorkflow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTesterRailOpen && (
|
{isTesterRailOpen && (
|
||||||
<aside className="hidden h-full w-[420px] shrink-0 border-l border-border xl:block">
|
<aside className="hidden h-full w-[400px] shrink-0 border-l border-border xl:block">
|
||||||
<WorkflowTesterPanel
|
<WorkflowTesterPanel
|
||||||
workflowId={workflowId}
|
workflowId={workflowId}
|
||||||
initialContextVariables={templateContextVariables}
|
initialContextVariables={templateContextVariables}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4413,6 +4413,10 @@ export type WorkflowListResponse = {
|
||||||
* Folder Id
|
* Folder Id
|
||||||
*/
|
*/
|
||||||
folder_id?: number | null;
|
folder_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Workflow Uuid
|
||||||
|
*/
|
||||||
|
workflow_uuid?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -4560,6 +4564,10 @@ export type WorkflowRunResponseSchema = {
|
||||||
* Recording Url
|
* Recording Url
|
||||||
*/
|
*/
|
||||||
recording_url: string | null;
|
recording_url: string | null;
|
||||||
|
/**
|
||||||
|
* Public Access Token
|
||||||
|
*/
|
||||||
|
public_access_token?: string | null;
|
||||||
/**
|
/**
|
||||||
* Cost Info
|
* Cost Info
|
||||||
*/
|
*/
|
||||||
|
|
@ -4709,6 +4717,10 @@ export type WorkflowRunUsageResponse = {
|
||||||
* Transcript Url
|
* Transcript Url
|
||||||
*/
|
*/
|
||||||
transcript_url?: string | null;
|
transcript_url?: string | null;
|
||||||
|
/**
|
||||||
|
* Public Access Token
|
||||||
|
*/
|
||||||
|
public_access_token?: string | null;
|
||||||
/**
|
/**
|
||||||
* Phone Number
|
* Phone Number
|
||||||
*
|
*
|
||||||
|
|
@ -5071,20 +5083,6 @@ export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses = {
|
||||||
|
|
||||||
export type HandlePlivoHangupCallbackApiV1TelephonyPlivoHangupCallbackWorkflowRunIdPostData = {
|
export type HandlePlivoHangupCallbackApiV1TelephonyPlivoHangupCallbackWorkflowRunIdPostData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
headers?: {
|
|
||||||
/**
|
|
||||||
* X-Plivo-Signature-V3
|
|
||||||
*/
|
|
||||||
'x-plivo-signature-v3'?: string | null;
|
|
||||||
/**
|
|
||||||
* X-Plivo-Signature-Ma-V3
|
|
||||||
*/
|
|
||||||
'x-plivo-signature-ma-v3'?: string | null;
|
|
||||||
/**
|
|
||||||
* X-Plivo-Signature-V3-Nonce
|
|
||||||
*/
|
|
||||||
'x-plivo-signature-v3-nonce'?: string | null;
|
|
||||||
};
|
|
||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* Workflow Run Id
|
* Workflow Run Id
|
||||||
|
|
@ -5117,20 +5115,6 @@ export type HandlePlivoHangupCallbackApiV1TelephonyPlivoHangupCallbackWorkflowRu
|
||||||
|
|
||||||
export type HandlePlivoRingCallbackApiV1TelephonyPlivoRingCallbackWorkflowRunIdPostData = {
|
export type HandlePlivoRingCallbackApiV1TelephonyPlivoRingCallbackWorkflowRunIdPostData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
headers?: {
|
|
||||||
/**
|
|
||||||
* X-Plivo-Signature-V3
|
|
||||||
*/
|
|
||||||
'x-plivo-signature-v3'?: string | null;
|
|
||||||
/**
|
|
||||||
* X-Plivo-Signature-Ma-V3
|
|
||||||
*/
|
|
||||||
'x-plivo-signature-ma-v3'?: string | null;
|
|
||||||
/**
|
|
||||||
* X-Plivo-Signature-V3-Nonce
|
|
||||||
*/
|
|
||||||
'x-plivo-signature-v3-nonce'?: string | null;
|
|
||||||
};
|
|
||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* Workflow Run Id
|
* Workflow Run Id
|
||||||
|
|
@ -5227,12 +5211,6 @@ export type HandleTelnyxTransferResultApiV1TelephonyTelnyxTransferResultTransfer
|
||||||
|
|
||||||
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData = {
|
export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
headers?: {
|
|
||||||
/**
|
|
||||||
* X-Webhook-Signature
|
|
||||||
*/
|
|
||||||
'x-webhook-signature'?: string | null;
|
|
||||||
};
|
|
||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* Workflow Run Id
|
* Workflow Run Id
|
||||||
|
|
@ -10075,6 +10053,86 @@ export type InitiateCallTestApiV1PublicAgentTestUuidPostResponses = {
|
||||||
|
|
||||||
export type InitiateCallTestApiV1PublicAgentTestUuidPostResponse = InitiateCallTestApiV1PublicAgentTestUuidPostResponses[keyof InitiateCallTestApiV1PublicAgentTestUuidPostResponses];
|
export type InitiateCallTestApiV1PublicAgentTestUuidPostResponse = InitiateCallTestApiV1PublicAgentTestUuidPostResponses[keyof InitiateCallTestApiV1PublicAgentTestUuidPostResponses];
|
||||||
|
|
||||||
|
export type InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostData = {
|
||||||
|
body: TriggerCallRequest;
|
||||||
|
headers: {
|
||||||
|
/**
|
||||||
|
* X-Api-Key
|
||||||
|
*/
|
||||||
|
'X-API-Key': string;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Workflow Uuid
|
||||||
|
*/
|
||||||
|
workflow_uuid: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/public/agent/workflow/{workflow_uuid}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostErrors = {
|
||||||
|
/**
|
||||||
|
* Not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostError = InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostErrors[keyof InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostErrors];
|
||||||
|
|
||||||
|
export type InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: TriggerCallResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostResponse = InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostResponses[keyof InitiateCallByWorkflowUuidApiV1PublicAgentWorkflowWorkflowUuidPostResponses];
|
||||||
|
|
||||||
|
export type InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostData = {
|
||||||
|
body: TriggerCallRequest;
|
||||||
|
headers: {
|
||||||
|
/**
|
||||||
|
* X-Api-Key
|
||||||
|
*/
|
||||||
|
'X-API-Key': string;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Workflow Uuid
|
||||||
|
*/
|
||||||
|
workflow_uuid: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/public/agent/test/workflow/{workflow_uuid}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostErrors = {
|
||||||
|
/**
|
||||||
|
* Not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostError = InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostErrors[keyof InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostErrors];
|
||||||
|
|
||||||
|
export type InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: TriggerCallResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostResponse = InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostResponses[keyof InitiateCallTestByWorkflowUuidApiV1PublicAgentTestWorkflowWorkflowUuidPostResponses];
|
||||||
|
|
||||||
export type DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData = {
|
export type DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue