feat: add logs in campaigns for failure or pausing (#265)

* feat: add logs in campaigns on failure

* chore: bump pipecat

* chore: update format.sh

* chore: fix github workflow

* fix: fix formatting errors
This commit is contained in:
Abhishek 2026-05-05 19:23:50 +05:30 committed by GitHub
parent abfb678b4d
commit d4b6afb020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 1001 additions and 245 deletions

View file

@ -1,14 +1,16 @@
name: Docs OpenAPI drift check
name: Pre-PR drift check
on:
pull_request:
branches: [main]
paths:
- 'api/**'
- 'ui/**'
- 'pipecat/**'
- 'scripts/dump_docs_openapi.py'
- 'scripts/format.sh'
- 'docs/api-reference/openapi.json'
- '.github/workflows/docs-openapi-drift.yml'
- '.github/workflows/pre-pr-drift-check.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -31,13 +33,45 @@ jobs:
cache: pip
cache-dependency-path: |
api/requirements.txt
api/requirements.dev.txt
pipecat/pyproject.toml
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install api dependencies
run: |
pip install -r api/requirements.txt
pip install -r api/requirements.dev.txt
pip install './pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb]'
- name: Install ui dependencies
working-directory: ui
run: npm ci
- name: Run scripts/format.sh
run: ./scripts/format.sh
- name: Check for Python format/lint drift
run: |
if ! git diff --exit-code api; then
echo "::error::Python files are not formatted. Run './scripts/format.sh' locally and commit the result before raising the PR."
exit 1
fi
echo "Python format/lint is clean."
- name: Check for UI lint drift
run: |
if ! git diff --exit-code ui; then
echo "::error::UI files have outstanding lint fixes. Run 'npm run fix-lint' inside ui/ locally and commit the result before raising the PR."
exit 1
fi
echo "UI lint is clean."
- name: Dump OpenAPI spec
env:
DATABASE_URL: postgresql+asyncpg://dummy:dummy@localhost/dummy
@ -47,7 +81,7 @@ jobs:
DEPLOYMENT_MODE: oss
run: python -u -m scripts.dump_docs_openapi
- name: Check for drift
- name: Check for OpenAPI drift
run: |
if ! git diff --exit-code docs/api-reference/openapi.json; then
echo "::error::docs/api-reference/openapi.json is out of sync with the FastAPI app."
@ -64,5 +98,5 @@ jobs:
with:
payload: |
{
"text": "❌ Dograh Docs OpenAPI drift check failed on ${{ github.ref_name }} by ${{ github.actor }} - <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
"text": "❌ Dograh pre-PR drift check failed on ${{ github.ref_name }} by ${{ github.actor }} - <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
}

View file

@ -0,0 +1,35 @@
"""add campaign logs column
Revision ID: 6499c608d0f6
Revises: a2355fc6bdc1
Create Date: 2026-05-05 17:25:49.235730
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6499c608d0f6"
down_revision: Union[str, None] = "a2355fc6bdc1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"campaigns",
sa.Column(
"logs", sa.JSON(), server_default=sa.text("'[]'::json"), nullable=False
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("campaigns", "logs")
# ### end Alembic commands ###

View file

@ -1,3 +1,4 @@
import json
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional
@ -134,35 +135,6 @@ class CampaignClient(BaseDBClient):
await session.refresh(campaign)
return campaign
async def update_campaign_progress(
self,
campaign_id: int,
processed_rows: int,
failed_rows: int,
organization_id: int,
) -> None:
"""Update campaign progress counters"""
async with self.async_session() as session:
query = select(CampaignModel).where(
CampaignModel.id == campaign_id,
CampaignModel.organization_id == organization_id,
)
result = await session.execute(query)
campaign = result.scalar_one_or_none()
if not campaign:
raise ValueError(f"Campaign {campaign_id} not found")
campaign.processed_rows = processed_rows
campaign.failed_rows = failed_rows
campaign.updated_at = datetime.now(UTC)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
async def get_campaign_runs(
self,
campaign_id: int,
@ -452,6 +424,48 @@ class CampaignClient(BaseDBClient):
await session.refresh(campaign)
return campaign
async def append_campaign_log(
self,
campaign_id: int,
level: str,
event: str,
message: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
"""Append a timestamped entry to the campaign's logs JSON array.
Uses a SQL-side jsonb concat so concurrent writers do not clobber
each other's entries.
"""
entry: Dict[str, Any] = {
"ts": datetime.now(UTC).isoformat(),
"level": level,
"event": event,
"message": message,
}
if details:
entry["details"] = details
async with self.async_session() as session:
await session.execute(
text(
"UPDATE campaigns "
"SET logs = (logs::jsonb || CAST(:entry AS jsonb))::json, "
" updated_at = :now "
"WHERE id = :campaign_id"
),
{
"entry": json.dumps([entry]),
"now": datetime.now(UTC),
"campaign_id": campaign_id,
},
)
try:
await session.commit()
except Exception:
await session.rollback()
raise
# QueuedRun methods
async def bulk_create_queued_runs(self, queued_runs_data: list[dict]) -> None:
"""Bulk create queued runs"""

View file

@ -683,6 +683,16 @@ class CampaignModel(Base):
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
)
# Append-only timestamped log entries for state transitions, failures,
# and circuit-breaker events. Surfaced in the UI so operators can see
# why a campaign moved to paused/failed without digging through logs.
logs = Column(
JSON,
nullable=False,
default=list,
server_default=text("'[]'::json"),
)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
started_at = Column(DateTime(timezone=True), nullable=True)

View file

@ -3,7 +3,6 @@ import os
import sys
import loguru
from pipecat.utils.run_context import run_id_var
from api.constants import (
ENVIRONMENT,
@ -16,6 +15,7 @@ from api.constants import (
)
from api.enums import Environment
from api.utils.worker import get_worker_id, is_worker_process
from pipecat.utils.run_context import run_id_var
# Track if logging has been initialized
_logging_initialized = False

View file

@ -17,13 +17,13 @@ from typing import Optional
from fastapi import APIRouter, WebSocket
from loguru import logger
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
from starlette.websockets import WebSocketDisconnect
from api.db import db_client
from api.enums import CallType, WorkflowRunState
from api.services.quota_service import check_dograh_quota_by_user_id
from api.services.telephony import registry as telephony_registry
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
router = APIRouter(prefix="/agent-stream")

View file

@ -1,6 +1,6 @@
import json
from datetime import datetime
from typing import List, Optional
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
@ -172,6 +172,20 @@ class UpdateCampaignRequest(BaseModel):
circuit_breaker: Optional[CircuitBreakerConfigRequest] = None
class CampaignLogEntryResponse(BaseModel):
"""A single timestamped entry from the campaign's append-only log.
Surfaced in the UI so operators can see why a campaign moved to
paused / failed without digging through server logs.
"""
ts: str
level: str
event: str
message: str
details: Optional[Dict[str, Any]] = None
class CampaignResponse(BaseModel):
id: int
name: str
@ -196,6 +210,7 @@ class CampaignResponse(BaseModel):
redialed_campaign_id: Optional[int] = None
telephony_configuration_id: Optional[int] = None
telephony_configuration_name: Optional[str] = None
logs: List[CampaignLogEntryResponse] = Field(default_factory=list)
class CampaignsResponse(BaseModel):
@ -298,6 +313,11 @@ def _build_campaign_response(
redialed_campaign_id=redialed_campaign_id,
telephony_configuration_id=campaign.telephony_configuration_id,
telephony_configuration_name=telephony_configuration_name,
logs=[
CampaignLogEntryResponse(**entry)
for entry in (campaign.logs or [])
if isinstance(entry, dict)
],
)

View file

@ -15,7 +15,6 @@ from fastapi import (
WebSocket,
)
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from pydantic import BaseModel, field_validator
from starlette.websockets import WebSocketDisconnect
@ -45,6 +44,7 @@ from api.utils.telephony_helper import (
numbers_match,
parse_webhook_request,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter(prefix="/telephony")

View file

@ -24,8 +24,6 @@ from aiortc import RTCIceServer
from aiortc.sdp import candidate_from_sdp
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from loguru import logger
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
from starlette.websockets import WebSocketState
from api.constants import ENVIRONMENT
@ -45,6 +43,8 @@ from api.services.pipecat.ws_sender_registry import (
unregister_ws_sender,
)
from api.services.quota_service import check_dograh_quota
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
router = APIRouter(prefix="/ws")

View file

@ -345,7 +345,12 @@ class CampaignCallDispatcher:
)
# Record call initiation failure in circuit breaker
await circuit_breaker.record_and_evaluate(campaign.id, is_failure=True)
await circuit_breaker.record_and_evaluate(
campaign.id,
is_failure=True,
workflow_run_id=workflow_run.id,
reason="call_initiation_failed",
)
# Release concurrent slot on failure
mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run.id)
@ -459,13 +464,18 @@ class CampaignCallDispatcher:
await asyncio.sleep(1)
async def acquire_from_number(
self, organization_id: int, timeout: float = 60
self, organization_id: int, timeout: float = 600
) -> Optional[str]:
"""
Acquire a from_number from the pool with retry.
Waits up to timeout seconds, polling every 1s.
Returns the phone number or None if timeout is exceeded.
Args:
organization_id: ID of the organization for which to acquire the from_number.
timeout: Maximum time in seconds to wait for a from_number before giving up.
Returns:
The acquired phone number as a string, or None if timeout is exceeded.
"""
wait_start = time.time()

View file

@ -383,6 +383,20 @@ class CampaignOrchestrator:
f"pausing campaign. Stats: {stats}"
)
await db_client.update_campaign(campaign_id=campaign_id, state="paused")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="warning",
event="circuit_breaker_tripped",
message=(
f"Paused at scheduling: failure rate "
f"{stats['failure_rate']:.2%} "
f"({stats['failure_count']}/"
f"{stats['failure_count'] + stats['success_count']}) "
f"exceeded threshold {stats['threshold']:.2%} "
f"in {stats['window_seconds']}s window"
),
details=stats,
)
await self.publisher.publish_circuit_breaker_tripped(
campaign_id=campaign_id,
failure_rate=stats["failure_rate"],

View file

@ -3,10 +3,15 @@
Uses two Redis sorted sets (ZSETs) per campaign one for failures, one for
successes as sliding windows. ZCARD gives O(1) counts without iterating
members, keeping the Lua scripts simple.
A separate capped Redis list (``cb_recent_failures:{campaign_id}``) stores the
last N failing ``{workflow_run_id, reason, ts}`` entries so the campaign log
written when the breaker trips can show *which* calls pushed it over.
"""
import json
import time
from typing import Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
import redis.asyncio as aioredis
from loguru import logger
@ -15,6 +20,11 @@ from api.constants import DEFAULT_CIRCUIT_BREAKER_CONFIG, REDIS_URL
from api.db import db_client
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
# Cap on the number of recent failure entries kept per campaign — large enough
# to be useful for debugging a trip, small enough that the JSON details stay
# bounded.
MAX_RECENT_FAILURES = 20
class CircuitBreaker:
"""Sliding window circuit breaker for campaign call failures."""
@ -35,6 +45,60 @@ class CircuitBreaker:
"""Return (failures_key, successes_key) for a campaign."""
return f"cb_failures:{campaign_id}", f"cb_successes:{campaign_id}"
@staticmethod
def _recent_failures_key(campaign_id: int) -> str:
"""Return the Redis key used for the capped recent-failures list."""
return f"cb_recent_failures:{campaign_id}"
async def _push_recent_failure(
self,
campaign_id: int,
workflow_run_id: int,
reason: Optional[str],
) -> None:
"""Push a failure entry onto the capped recent-failures list."""
redis_client = await self._get_redis()
key = self._recent_failures_key(campaign_id)
entry = json.dumps(
{
"workflow_run_id": workflow_run_id,
"reason": reason,
"ts": time.time(),
}
)
try:
await redis_client.lpush(key, entry)
await redis_client.ltrim(key, 0, MAX_RECENT_FAILURES - 1)
# Keep this list around as long as the sliding window plus a buffer.
await redis_client.expire(
key,
DEFAULT_CIRCUIT_BREAKER_CONFIG["window_seconds"] + 60,
)
except Exception as e:
# Never let recent-failure bookkeeping disrupt the call path.
logger.error(
f"Failed to record recent failure for campaign {campaign_id}: {e}"
)
async def _get_recent_failures(self, campaign_id: int) -> List[Dict[str, Any]]:
"""Return the recent-failures list (most-recent first)."""
redis_client = await self._get_redis()
key = self._recent_failures_key(campaign_id)
try:
entries = await redis_client.lrange(key, 0, -1)
except Exception as e:
logger.error(
f"Failed to read recent failures for campaign {campaign_id}: {e}"
)
return []
decoded: List[Dict[str, Any]] = []
for raw in entries:
try:
decoded.append(json.loads(raw))
except (TypeError, ValueError):
continue
return decoded
async def record_call_outcome(
self,
campaign_id: int,
@ -227,13 +291,25 @@ class CircuitBreaker:
logger.error(f"Circuit breaker check error for campaign {campaign_id}: {e}")
return False, None
async def record_and_evaluate(self, campaign_id: int, is_failure: bool) -> None:
async def record_and_evaluate(
self,
campaign_id: int,
is_failure: bool,
*,
workflow_run_id: Optional[int] = None,
reason: Optional[str] = None,
) -> None:
"""Record a call outcome, and if the breaker trips, pause the campaign.
This is the main entry point called from telephony status callbacks.
It handles fetching campaign config, recording the outcome, and
pausing + publishing an event if the breaker trips.
``workflow_run_id`` and ``reason`` are optional but should be supplied
on failures: they are appended to a capped Redis list so the campaign
log entry written on trip can name the calls that pushed the breaker
over the threshold.
Exceptions are caught internally so this never disrupts the caller.
"""
try:
@ -245,6 +321,13 @@ class CircuitBreaker:
if campaign.orchestrator_metadata:
cb_config = campaign.orchestrator_metadata.get("circuit_breaker", {})
if is_failure and workflow_run_id is not None:
await self._push_recent_failure(
campaign_id=campaign_id,
workflow_run_id=workflow_run_id,
reason=reason,
)
tripped, stats = await self.record_call_outcome(
campaign_id=campaign_id,
is_failure=is_failure,
@ -257,7 +340,22 @@ class CircuitBreaker:
f"pausing campaign. Stats: {stats}"
)
recent_failures = await self._get_recent_failures(campaign_id)
await db_client.update_campaign(campaign_id=campaign_id, state="paused")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="warning",
event="circuit_breaker_tripped",
message=(
f"Paused: failure rate {stats['failure_rate']:.2%} "
f"({stats['failure_count']}/"
f"{stats['failure_count'] + stats['success_count']}) "
f"exceeded threshold {stats['threshold']:.2%} "
f"in {stats['window_seconds']}s window"
),
details={**stats, "recent_failures": recent_failures},
)
publisher = await get_campaign_event_publisher()
await publisher.publish_circuit_breaker_tripped(
@ -275,13 +373,16 @@ class CircuitBreaker:
async def reset(self, campaign_id: int) -> bool:
"""Reset the circuit breaker state for a campaign.
Called when a campaign is resumed to give it a clean slate.
Called when a campaign is resumed to give it a clean slate. Also clears
the recent-failures list so log entries from the next trip reference
only post-resume failures.
"""
redis_client = await self._get_redis()
fail_key, succ_key = self._keys(campaign_id)
recent_key = self._recent_failures_key(campaign_id)
try:
await redis_client.delete(fail_key, succ_key)
await redis_client.delete(fail_key, succ_key, recent_key)
logger.info(f"Circuit breaker reset for campaign {campaign_id}")
return True
except Exception as e:

View file

@ -9,6 +9,7 @@ import asyncio
from typing import Dict, Set
from loguru import logger
from pipecat.audio.utils import mix_audio
from pipecat.frames.frames import (
Frame,

View file

@ -3,10 +3,6 @@
from typing import Any, Dict
from loguru import logger
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
from api.db.db_client import DBClient
from api.services.looptalk.audio_streamer import get_or_create_audio_streamer
@ -27,6 +23,10 @@ from api.services.pipecat.service_factory import (
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
class LoopTalkPipelineBuilder:

View file

@ -7,6 +7,7 @@
"""Internal frame serializer for agent-to-agent communication."""
from loguru import logger
from pipecat.frames.frames import (
Frame,
InputAudioRawFrame,

View file

@ -11,6 +11,8 @@ import time
from typing import Dict, Optional, Tuple
from loguru import logger
from api.services.looptalk.internal_serializer import InternalFrameSerializer
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
@ -27,8 +29,6 @@ from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams
from api.services.looptalk.internal_serializer import InternalFrameSerializer
class InternalInputTransport(BaseInputTransport):
"""Input side of internal transport for agent-to-agent communication."""

View file

@ -6,8 +6,6 @@ from pathlib import Path
from typing import Any, Dict, Optional
from loguru import logger
from pipecat.pipeline.task import PipelineTask
from pipecat.utils.run_context import set_current_run_id
from api.db.db_client import DBClient
from api.services.looptalk.internal_transport import (
@ -15,6 +13,8 @@ from api.services.looptalk.internal_transport import (
InternalTransportManager,
)
from api.services.pipecat.transport_setup import create_internal_transport
from pipecat.pipeline.task import PipelineTask
from pipecat.utils.run_context import set_current_run_id
from .core.pipeline_builder import LoopTalkPipelineBuilder
from .core.recording_manager import RecordingManager

View file

@ -235,7 +235,10 @@ def register_event_handlers(
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if workflow_run and workflow_run.campaign_id:
await circuit_breaker.record_and_evaluate(
campaign_id=workflow_run.campaign_id, is_failure=True
campaign_id=workflow_run.campaign_id,
is_failure=True,
workflow_run_id=workflow_run_id,
reason="pipeline_error",
)
asyncio.create_task(
_capture_call_event(

View file

@ -21,9 +21,10 @@ from fastapi import (
status,
)
from fastapi.websockets import WebSocketState
from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2
from scipy.io import wavfile
from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2
LOG_LEVEL = (
logging.DEBUG
if os.environ.get("LOG_LEVEL", "DEBUG").lower() == "debug"

View file

@ -20,6 +20,7 @@ from typing import Any, Dict, Optional
import numpy as np
import websockets
from loguru import logger
from pipecat.audio.turn.smart_turn.base_smart_turn import (
BaseSmartTurn,
SmartTurnTimeoutException,

View file

@ -6,6 +6,7 @@ This module contains the business logic for Asterisk ARI call operations.
from typing import Any, Dict
from loguru import logger
from pipecat.serializers.call_strategies import HangupStrategy, TransferStrategy

View file

@ -1,14 +1,14 @@
"""ARI (Asterisk) transport factory."""
from fastapi import WebSocket
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import AsteriskFrameSerializer
from .strategies import ARIBridgeSwapStrategy, ARIHangupStrategy

View file

@ -8,7 +8,6 @@ import json
from fastapi import APIRouter, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider_for_run
@ -16,6 +15,7 @@ from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()

View file

@ -3,9 +3,9 @@
from typing import Any, Dict
from loguru import logger
from pipecat.serializers.call_strategies import HangupStrategy
from api.services.telephony.providers.cloudonix.provider import CLOUDONIX_API_BASE_URL
from pipecat.serializers.call_strategies import HangupStrategy
class CloudonixHangupStrategy(HangupStrategy):

View file

@ -1,14 +1,14 @@
"""Cloudonix transport factory."""
from fastapi import WebSocket
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import CloudonixFrameSerializer
from .strategies import CloudonixHangupStrategy

View file

@ -9,7 +9,6 @@ from typing import Optional
from fastapi import APIRouter, Header, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from starlette.responses import HTMLResponse
from api.db import db_client
@ -19,6 +18,7 @@ from api.services.telephony.status_processor import (
_process_status_update,
)
from api.utils.common import get_backend_endpoints
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()

View file

@ -1,14 +1,14 @@
"""Plivo transport factory."""
from fastapi import WebSocket
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import PlivoFrameSerializer

View file

@ -8,7 +8,6 @@ import json
from fastapi import APIRouter, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider_for_run
@ -17,6 +16,7 @@ from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()

View file

@ -1,14 +1,14 @@
"""Telnyx transport factory."""
from fastapi import WebSocket
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import TelnyxFrameSerializer

View file

@ -9,7 +9,6 @@ from typing import Optional
from fastapi import APIRouter, Header, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from starlette.responses import HTMLResponse
from api.db import db_client
@ -19,6 +18,7 @@ from api.services.telephony.status_processor import (
_process_status_update,
)
from api.utils.common import get_backend_endpoints
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()

View file

@ -8,6 +8,7 @@ from typing import Any, Dict
import aiohttp
from loguru import logger
from pipecat.serializers.call_strategies import HangupStrategy, TransferStrategy

View file

@ -1,14 +1,14 @@
"""Twilio transport factory."""
from fastapi import WebSocket
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import TwilioFrameSerializer
from .strategies import TwilioConferenceStrategy, TwilioHangupStrategy

View file

@ -10,7 +10,6 @@ from typing import Optional
from fastapi import APIRouter, Header, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from starlette.responses import HTMLResponse
from api.db import db_client
@ -25,6 +24,7 @@ from api.utils.common import get_backend_endpoints
from api.utils.telephony_helper import (
parse_webhook_request,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()

View file

@ -7,14 +7,14 @@ Vobiz uses Plivo-compatible WebSocket protocol:
from fastapi import WebSocket
from loguru import logger
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import VobizFrameSerializer

View file

@ -9,7 +9,6 @@ from typing import Optional
from fastapi import APIRouter, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider_for_run
@ -17,6 +16,7 @@ from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from pipecat.utils.run_context import set_current_run_id
router = APIRouter()

View file

@ -1,13 +1,12 @@
"""Vonage transport factory."""
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_mixer import build_audio_out_mixer
from api.services.telephony.factory import load_credentials_for_transport
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
)
from .serializers import VonageFrameSerializer

View file

@ -179,9 +179,12 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
is_failure = status.status in ("error", "failed")
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id,
is_failure=status.status in ("error", "failed"),
is_failure=is_failure,
workflow_run_id=workflow_run_id if is_failure else None,
reason=status.status if is_failure else None,
)
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:

View file

@ -1,5 +1,9 @@
from typing import TYPE_CHECKING, Awaitable, Callable, Optional, Union
from api.db import db_client
from api.services.pipecat.audio_playback import play_audio
from api.services.workflow.disposition_mapper import apply_disposition_mapping
from api.services.workflow.workflow import Node, WorkflowGraph
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
@ -15,11 +19,6 @@ from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.settings import LLMSettings
from pipecat.utils.enums import EndTaskReason
from api.db import db_client
from api.services.pipecat.audio_playback import play_audio
from api.services.workflow.disposition_mapper import apply_disposition_mapping
from api.services.workflow.workflow import Node, WorkflowGraph
if TYPE_CHECKING:
from pipecat.frames.frames import Frame
from pipecat.services.anthropic.llm import AnthropicLLMService

View file

@ -14,6 +14,7 @@ import re
from typing import TYPE_CHECKING
from loguru import logger
from pipecat.frames.frames import (
LLMMessagesAppendFrame,
)

View file

@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Optional
from loguru import logger
from opentelemetry import trace
from api.services.pipecat.tracing_config import ensure_tracing
from pipecat.frames.frames import LLMContextSummaryRequestFrame
from pipecat.utils.context.llm_context_summarization import (
LLMContextSummarizationUtil,
@ -13,8 +15,6 @@ from pipecat.utils.context.llm_context_summarization import (
)
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
from api.services.pipecat.tracing_config import ensure_tracing
if TYPE_CHECKING:
from api.services.workflow.pipecat_engine import PipecatEngine

View file

@ -13,13 +13,6 @@ import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from loguru import logger
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import (
FunctionCallResultProperties,
TTSSpeakFrame,
)
from pipecat.services.llm_service import FunctionCallParams
from pipecat.utils.enums import EndTaskReason
from api.db import db_client
from api.enums import ToolCategory, WorkflowRunMode
@ -32,6 +25,13 @@ from api.services.workflow.tools.custom_tool import (
execute_http_tool,
tool_to_function_schema,
)
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import (
FunctionCallResultProperties,
TTSSpeakFrame,
)
from pipecat.services.llm_service import FunctionCallParams
from pipecat.utils.enums import EndTaskReason
if TYPE_CHECKING:
from api.services.workflow.pipecat_engine import PipecatEngine

View file

@ -5,12 +5,12 @@ from typing import TYPE_CHECKING, Any, List
from loguru import logger
from opentelemetry import trace
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
from api.services.gen_ai.json_parser import parse_llm_json
from api.services.pipecat.tracing_config import ensure_tracing
from api.services.workflow.dto import ExtractionVariableDTO
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
if TYPE_CHECKING:
from api.services.workflow.pipecat_engine import PipecatEngine

View file

@ -4,7 +4,6 @@ import json
from typing import Any
from loguru import logger
from pipecat.processors.aggregators.llm_context import LLMContext
from api.db.models import WorkflowRunModel
from api.services.gen_ai.json_parser import parse_llm_json
@ -27,6 +26,7 @@ from api.services.workflow.qa.tracing import (
setup_langfuse_parent_context,
)
from api.utils.template_renderer import render_template
from pipecat.processors.aggregators.llm_context import LLMContext
async def _run_llm_inference(

View file

@ -3,7 +3,6 @@
from typing import Any
from loguru import logger
from pipecat.processors.aggregators.llm_context import LLMContext
from api.db import db_client
from api.db.models import WorkflowRunModel
@ -11,6 +10,7 @@ from api.services.pipecat.service_factory import create_llm_service_from_provide
from api.services.workflow.dto import NodeType, QANodeData
from api.services.workflow.qa.llm_config import resolve_llm_config
from api.services.workflow.qa.tracing import create_node_summary_trace
from pipecat.processors.aggregators.llm_context import LLMContext
NODE_SUMMARY_SYSTEM_PROMPT = (
"You are analyzing a voice AI agent script. This is only a part of a larger script. "

View file

@ -78,6 +78,7 @@ def add_qa_span_to_trace(
return
try:
from opentelemetry import trace as otel_trace
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
tracer = otel_trace.get_tracer("pipecat")
@ -121,9 +122,9 @@ def create_node_summary_trace(
try:
from opentelemetry import trace as otel_trace
from opentelemetry.context import Context
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
from api.services.pipecat.tracing_config import ensure_tracing
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
if not ensure_tracing():
return None

View file

@ -8,7 +8,10 @@ from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatc
from api.services.campaign.campaign_event_publisher import (
get_campaign_event_publisher,
)
from api.services.campaign.errors import ConcurrentSlotAcquisitionError
from api.services.campaign.errors import (
ConcurrentSlotAcquisitionError,
PhoneNumberPoolExhaustedError,
)
from api.services.campaign.source_sync_factory import get_sync_service
@ -80,6 +83,13 @@ async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None:
source_sync_status="failed",
source_sync_error=str(e),
)
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="source_sync_failed",
message=f"Source sync failed: {e}",
details={"error": str(e)},
)
raise
@ -137,6 +147,39 @@ async def process_campaign_batch(
# Update campaign state to failed
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="batch_failed",
message=f"Concurrent slot acquisition timeout: {e}",
details={"error": str(e), "reason": "concurrent_slot_timeout"},
)
raise
except PhoneNumberPoolExhaustedError as e:
logger.warning(f"Phone number pool exhausted for campaign {campaign_id}: {e}")
publisher = await get_campaign_event_publisher()
await publisher.publish_batch_failed(
campaign_id=campaign_id,
error=f"Phone number pool exhausted: {e}",
processed_count=0,
)
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="phone_number_pool_exhausted",
message=(
f"Phone number pool exhausted for org {e.organization_id}: "
"no free from_number available to dispatch outbound calls"
),
details={
"error": str(e),
"organization_id": e.organization_id,
},
)
raise
except Exception as e:
@ -152,4 +195,11 @@ async def process_campaign_batch(
# Update campaign state to failed
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
await db_client.append_campaign_log(
campaign_id=campaign_id,
level="error",
event="batch_failed",
message=f"Batch processing failed: {e}",
details={"error": str(e)},
)
raise

View file

@ -5,8 +5,6 @@ from typing import Any, Dict, Optional
import httpx
from loguru import logger
from pipecat.utils.enums import EndTaskReason
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
from pydantic import ValidationError
from api.constants import BACKEND_API_ENDPOINT
@ -23,6 +21,8 @@ from api.services.workflow.dto import (
from api.services.workflow.qa import run_per_node_qa_analysis
from api.utils.credential_auth import build_auth_header
from api.utils.template_renderer import render_template
from pipecat.utils.enums import EndTaskReason
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
def _should_skip_qa(

View file

@ -2,12 +2,12 @@ import os
from typing import Optional
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.pricing.workflow_run_cost import calculate_workflow_run_cost
from api.services.storage import get_current_storage_backend, storage_fs
from api.tasks.run_integrations import run_integrations_post_workflow_run
from pipecat.utils.run_context import set_current_run_id
async def upload_voicemail_audio_to_s3(

View file

@ -29,12 +29,11 @@ from contextlib import ExitStack, contextmanager
from typing import Any
from unittest.mock import AsyncMock, patch
from api.db.models import OrganizationModel, UserModel
from api.enums import WorkflowRunMode
from pipecat.frames.frames import Frame
from pipecat.observers.base_observer import BaseObserver
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from api.db.models import OrganizationModel, UserModel
from api.enums import WorkflowRunMode
from pipecat.tests import MockLLMService, MockTTSService
USER_CONFIGURATION: dict[str, Any] = {

View file

@ -17,8 +17,6 @@ completion flag, and ``gathered_context`` entries.
import asyncio
import pytest
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from api.enums import WorkflowRunMode, WorkflowRunState
from api.services.pipecat.audio_config import create_audio_config
@ -27,6 +25,8 @@ from api.tests.integrations._run_pipeline_helpers import (
create_workflow_run_rows,
patch_run_pipeline_externals,
)
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
WORKFLOW_DEFINITION = {
"nodes": [

View file

@ -28,10 +28,6 @@ deterministic and the synthesised audio length is short.
import asyncio
import pytest
from pipecat.frames.frames import TranscriptionFrame
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.utils.time import time_now_iso8601
from api.enums import WorkflowRunMode, WorkflowRunState
from api.services.pipecat.audio_config import create_audio_config
@ -40,7 +36,11 @@ from api.tests.integrations._run_pipeline_helpers import (
create_workflow_run_rows,
patch_run_pipeline_externals,
)
from pipecat.frames.frames import TranscriptionFrame
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.utils.time import time_now_iso8601
GREETING_TEXT = (
"Thanks for calling Happy Feet, this is Sarah. How can I help you today?"

View file

@ -0,0 +1,87 @@
"""
Tests for api.tasks.campaign_tasks failure handling.
Specifically: each kind of failure that pauses or fails a campaign should
write a specific, identifiable entry into the campaign log so operators
can tell at a glance why a campaign stopped.
"""
from unittest.mock import AsyncMock, patch
import pytest
from api.services.campaign.errors import (
ConcurrentSlotAcquisitionError,
PhoneNumberPoolExhaustedError,
)
from api.tasks.campaign_tasks import process_campaign_batch
class TestProcessCampaignBatchFailureLogs:
"""``process_campaign_batch`` should log a *specific* event for each
distinct failure mode, not collapse them all into a generic
``batch_failed`` entry."""
@pytest.mark.asyncio
async def test_phone_number_pool_exhausted_logs_specific_event(self):
"""When PhoneNumberPoolExhaustedError propagates from process_batch,
the campaign log entry should use event='phone_number_pool_exhausted'
with a clear message not the generic 'batch_failed' bucket."""
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.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_pub = AsyncMock()
mock_get_pub.return_value = mock_pub
with pytest.raises(PhoneNumberPoolExhaustedError):
await process_campaign_batch({}, campaign_id=42)
mock_db.update_campaign.assert_called_once_with(
campaign_id=42, state="failed"
)
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"
assert kwargs["level"] == "error"
assert "phone number" in kwargs["message"].lower()
assert kwargs["details"]["organization_id"] == 7
@pytest.mark.asyncio
async def test_concurrent_slot_timeout_still_logs_specific_event(self):
"""Regression guard: the existing ConcurrentSlotAcquisitionError branch
should keep logging its specific reason."""
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=ConcurrentSlotAcquisitionError(
organization_id=7, campaign_id=42, wait_time=30.0
)
)
mock_db.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_pub = AsyncMock()
mock_get_pub.return_value = mock_pub
with pytest.raises(ConcurrentSlotAcquisitionError):
await process_campaign_batch({}, campaign_id=42)
mock_db.append_campaign_log.assert_called_once()
kwargs = mock_db.append_campaign_log.call_args.kwargs
assert kwargs["event"] == "batch_failed"
assert kwargs["details"]["reason"] == "concurrent_slot_timeout"

View file

@ -198,7 +198,9 @@ class TestCircuitBreakerReset:
result = await cb.reset(campaign_id=42)
assert result is True
mock_redis.delete.assert_called_once_with("cb_failures:42", "cb_successes:42")
mock_redis.delete.assert_called_once_with(
"cb_failures:42", "cb_successes:42", "cb_recent_failures:42"
)
@pytest.mark.asyncio
async def test_reset_on_redis_error(self):
@ -253,6 +255,7 @@ class TestRecordAndEvaluate:
):
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
mock_db.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_publisher = AsyncMock()
mock_get_publisher.return_value = mock_publisher
@ -352,6 +355,206 @@ class TestRecordAndEvaluate:
await cb.record_and_evaluate(campaign_id=42, is_failure=True)
# =============================================================================
# Tests for recent-failures tracking (workflow_run_id + reason)
# =============================================================================
class TestCircuitBreakerRecentFailures:
"""When a call fails, the circuit breaker should remember the workflow_run_id
and reason in a capped Redis list, and surface those entries in the campaign
log entry written when the breaker trips."""
@pytest.mark.asyncio
async def test_failure_pushes_recent_failure_entry(self):
"""is_failure=True with run id + reason should push to recent-failures list."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_campaign = MagicMock()
mock_campaign.id = 42
mock_campaign.state = "running"
mock_campaign.orchestrator_metadata = {}
with patch("api.services.campaign.circuit_breaker.db_client") as mock_db:
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
mock_db.append_campaign_log = AsyncMock()
cb.record_call_outcome = AsyncMock(return_value=(False, None))
cb._push_recent_failure = AsyncMock()
cb._get_recent_failures = AsyncMock(return_value=[])
await cb.record_and_evaluate(
campaign_id=42,
is_failure=True,
workflow_run_id=100,
reason="failed",
)
cb._push_recent_failure.assert_called_once_with(
campaign_id=42, workflow_run_id=100, reason="failed"
)
@pytest.mark.asyncio
async def test_success_does_not_push_recent_failure(self):
"""is_failure=False must not push to the recent-failures list."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_campaign = MagicMock()
mock_campaign.id = 42
mock_campaign.state = "running"
mock_campaign.orchestrator_metadata = {}
with patch("api.services.campaign.circuit_breaker.db_client") as mock_db:
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
cb.record_call_outcome = AsyncMock(return_value=(False, None))
cb._push_recent_failure = AsyncMock()
cb._get_recent_failures = AsyncMock(return_value=[])
await cb.record_and_evaluate(
campaign_id=42,
is_failure=False,
workflow_run_id=100,
reason=None,
)
cb._push_recent_failure.assert_not_called()
@pytest.mark.asyncio
async def test_trip_log_includes_recent_failures_in_details(self):
"""When the breaker trips, the campaign log entry's details should include
recent_failures fetched from the Redis list."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_campaign = MagicMock()
mock_campaign.id = 42
mock_campaign.state = "running"
mock_campaign.orchestrator_metadata = {}
stats = {
"failure_rate": 0.6,
"failure_count": 6,
"success_count": 4,
"threshold": 0.5,
"window_seconds": 120,
}
recent = [
{"workflow_run_id": 100, "reason": "failed", "ts": 1700000010.0},
{"workflow_run_id": 99, "reason": "error", "ts": 1700000000.0},
]
with (
patch("api.services.campaign.circuit_breaker.db_client") as mock_db,
patch(
"api.services.campaign.circuit_breaker.get_campaign_event_publisher"
) as mock_get_publisher,
):
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
mock_db.update_campaign = AsyncMock()
mock_db.append_campaign_log = AsyncMock()
mock_publisher = AsyncMock()
mock_get_publisher.return_value = mock_publisher
cb.record_call_outcome = AsyncMock(return_value=(True, stats))
cb._push_recent_failure = AsyncMock()
cb._get_recent_failures = AsyncMock(return_value=recent)
await cb.record_and_evaluate(
campaign_id=42,
is_failure=True,
workflow_run_id=100,
reason="failed",
)
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"] == "circuit_breaker_tripped"
assert kwargs["details"]["recent_failures"] == recent
@pytest.mark.asyncio
async def test_push_recent_failure_uses_lpush_and_ltrim(self):
"""_push_recent_failure should LPUSH a JSON entry and LTRIM the list
to keep only the most recent N (default 20)."""
import json
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_redis = AsyncMock()
mock_redis.lpush = AsyncMock(return_value=1)
mock_redis.ltrim = AsyncMock(return_value=True)
mock_redis.expire = AsyncMock(return_value=True)
cb.redis_client = mock_redis
await cb._push_recent_failure(
campaign_id=42, workflow_run_id=100, reason="failed"
)
# Verify the key used
mock_redis.lpush.assert_called_once()
push_args = mock_redis.lpush.call_args.args
assert push_args[0] == "cb_recent_failures:42"
# Verify the payload includes the run id + reason
entry = json.loads(push_args[1])
assert entry["workflow_run_id"] == 100
assert entry["reason"] == "failed"
assert "ts" in entry
# Verify the cap (LTRIM 0 19 keeps 20 entries)
mock_redis.ltrim.assert_called_once_with("cb_recent_failures:42", 0, 19)
@pytest.mark.asyncio
async def test_get_recent_failures_decodes_lrange(self):
"""_get_recent_failures should LRANGE the list and JSON-decode entries."""
import json
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_redis = AsyncMock()
entries = [
json.dumps({"workflow_run_id": 100, "reason": "failed", "ts": 1.0}),
json.dumps({"workflow_run_id": 99, "reason": "error", "ts": 0.5}),
]
mock_redis.lrange = AsyncMock(return_value=entries)
cb.redis_client = mock_redis
result = await cb._get_recent_failures(campaign_id=42)
mock_redis.lrange.assert_called_once_with("cb_recent_failures:42", 0, -1)
assert result == [
{"workflow_run_id": 100, "reason": "failed", "ts": 1.0},
{"workflow_run_id": 99, "reason": "error", "ts": 0.5},
]
@pytest.mark.asyncio
async def test_reset_clears_recent_failures_key(self):
"""reset() must also delete cb_recent_failures:{campaign_id}."""
from api.services.campaign.circuit_breaker import CircuitBreaker
cb = CircuitBreaker()
mock_redis = AsyncMock()
mock_redis.delete = AsyncMock(return_value=3)
cb.redis_client = mock_redis
await cb.reset(campaign_id=42)
mock_redis.delete.assert_called_once_with(
"cb_failures:42", "cb_successes:42", "cb_recent_failures:42"
)
# =============================================================================
# Integration tests: _process_status_update calls circuit_breaker
# =============================================================================
@ -405,7 +608,12 @@ class TestProcessStatusUpdateCircuitBreaker:
await _process_status_update(100, status)
mock_cb.record_and_evaluate.assert_called_once_with(42, is_failure=True)
mock_cb.record_and_evaluate.assert_called_once_with(
42,
is_failure=True,
workflow_run_id=100,
reason="failed",
)
@pytest.mark.asyncio
async def test_success_status_calls_record_and_evaluate(self):

View file

@ -12,6 +12,12 @@ from typing import Any, Dict
from unittest.mock import AsyncMock, Mock, patch
import pytest
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
from api.services.workflow.tools.custom_tool import (
execute_http_tool,
tool_to_function_schema,
)
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import (
FunctionCallInProgressFrame,
@ -25,12 +31,6 @@ from pipecat.frames.frames import (
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.llm_service import FunctionCallParams
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
from api.services.workflow.tools.custom_tool import (
execute_http_tool,
tool_to_function_schema,
)
from pipecat.tests import MockLLMService, run_test
@ -720,11 +720,10 @@ class TestCustomToolManagerUnit:
@pytest.mark.asyncio
async def test_get_tool_schemas_returns_correct_format(self):
"""Test that get_tool_schemas returns FunctionSchema objects."""
from pipecat.adapters.schemas.function_schema import FunctionSchema
# Create a mock engine
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
from pipecat.adapters.schemas.function_schema import FunctionSchema
mock_engine = Mock()
mock_engine._workflow_run_id = 1

View file

@ -9,15 +9,15 @@ This module tests the full flow of:
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.processors.aggregators.llm_context import LLMContext
from api.services.workflow.pipecat_engine_custom_tools import (
CustomToolManager,
get_function_schema,
)
from api.tests.conftest import MockToolModel
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.processors.aggregators.llm_context import LLMContext
def _update_llm_context(context, system_message, functions):

View file

@ -18,6 +18,14 @@ from typing import List
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from api.tests.conftest import (
AGENT_SYSTEM_PROMPT,
END_CALL_SYSTEM_PROMPT,
START_CALL_SYSTEM_PROMPT,
)
from pipecat.frames.frames import LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
@ -27,21 +35,13 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMAssistantAggregatorParams,
LLMContextAggregatorPair,
)
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from api.tests.conftest import (
AGENT_SYSTEM_PROMPT,
END_CALL_SYSTEM_PROMPT,
START_CALL_SYSTEM_PROMPT,
)
from pipecat.tests import (
ContextCapturingMockLLM,
MockLLMService,
MockTTSService,
)
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
async def run_pipeline_and_capture_context(

View file

@ -23,23 +23,6 @@ from typing import Any, Dict, List
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.frames.frames import Frame, LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMAssistantAggregatorParams,
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.turns.user_mute import (
CallbackUserMuteStrategy,
MuteUntilFirstBotCompleteUserMuteStrategy,
)
from pipecat.utils.enums import EndTaskReason
from api.enums import ToolCategory
from api.services.workflow.dto import (
@ -59,7 +42,24 @@ from api.services.workflow.pipecat_engine_variable_extractor import (
)
from api.services.workflow.workflow import WorkflowGraph
from api.tests.conftest import END_CALL_SYSTEM_PROMPT, START_CALL_SYSTEM_PROMPT
from pipecat.frames.frames import Frame, LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMAssistantAggregatorParams,
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.turns.user_mute import (
CallbackUserMuteStrategy,
MuteUntilFirstBotCompleteUserMuteStrategy,
)
from pipecat.utils.enums import EndTaskReason
class EndCallTestHelper:

View file

@ -15,6 +15,9 @@ import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from pipecat.frames.frames import (
Frame,
FunctionCallResultFrame,
@ -33,6 +36,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMUserAggregatorParams,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.turns.user_mute import (
@ -48,10 +52,6 @@ from pipecat.turns.user_stop import (
from pipecat.turns.user_turn_strategies import UserTurnStrategies
from pipecat.utils.time import time_now_iso8601
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from pipecat.tests import MockLLMService, MockTTSService
class UserSpeechInjector(FrameProcessor):
"""Processor that injects user speaking frames on FunctionCallResultFrame.

View file

@ -9,6 +9,10 @@ from typing import Any, Dict, List
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from api.tests.conftest import END_CALL_SYSTEM_PROMPT
from pipecat.frames.frames import LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
@ -18,14 +22,10 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMAssistantAggregatorParams,
LLMContextAggregatorPair,
)
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from api.tests.conftest import END_CALL_SYSTEM_PROMPT
from pipecat.tests import MockLLMService, MockTTSService
async def run_pipeline_with_tool_calls(
workflow: WorkflowGraph,

View file

@ -13,6 +13,12 @@ import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.frames.frames import LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
@ -23,6 +29,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.turns.user_mute import (
@ -31,13 +38,6 @@ from pipecat.turns.user_mute import (
MuteUntilFirstBotCompleteUserMuteStrategy,
)
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.tests import MockLLMService, MockTTSService
async def _build_engine_and_pipeline(
workflow: WorkflowGraph,

View file

@ -16,6 +16,12 @@ from typing import Any, Dict, List
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.frames.frames import LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
@ -25,16 +31,10 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMAssistantAggregatorParams,
LLMContextAggregatorPair,
)
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.tests import MockLLMService, MockTTSService
class TestVariableExtractionDuringTransitions:
"""Test that variable extraction is triggered for the correct node during transitions."""

View file

@ -2,6 +2,7 @@ import asyncio
import pytest
from loguru import logger
from pipecat.frames.frames import (
EndTaskFrame,
Frame,

View file

@ -12,14 +12,6 @@ and inspect what arrives downstream.
from typing import Optional
import pytest
from pipecat.frames.frames import (
LLMFullResponseEndFrame,
LLMTextFrame,
TTSAudioRawFrame,
TTSStartedFrame,
TTSStoppedFrame,
TTSTextFrame,
)
from api.services.pipecat.recording_audio_cache import RecordingAudio
from api.services.pipecat.recording_router_processor import (
@ -29,6 +21,14 @@ from api.services.workflow.pipecat_engine_context_composer import (
RECORDING_MARKER,
TTS_MARKER,
)
from pipecat.frames.frames import (
LLMFullResponseEndFrame,
LLMTextFrame,
TTSAudioRawFrame,
TTSStartedFrame,
TTSStoppedFrame,
TTSTextFrame,
)
from pipecat.tests import run_test
# ---------------------------------------------------------------------------

View file

@ -11,6 +11,21 @@ from typing import Any, Dict, List
from unittest.mock import AsyncMock, Mock, patch
import pytest
from api.services.pipecat.recording_audio_cache import RecordingAudio
from api.services.workflow.dto import (
EdgeDataDTO,
EndCallNodeData,
EndCallRFNode,
Position,
ReactFlowDTO,
RFEdgeDTO,
StartCallNodeData,
StartCallRFNode,
)
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
from api.services.workflow.workflow import WorkflowGraph
from pipecat.frames.frames import (
Frame,
LLMContextFrame,
@ -27,25 +42,10 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMAssistantAggregatorParams,
LLMContextAggregatorPair,
)
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from api.services.pipecat.recording_audio_cache import RecordingAudio
from api.services.workflow.dto import (
EdgeDataDTO,
EndCallNodeData,
EndCallRFNode,
Position,
ReactFlowDTO,
RFEdgeDTO,
StartCallNodeData,
StartCallRFNode,
)
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
from api.services.workflow.workflow import WorkflowGraph
from pipecat.tests import MockLLMService, MockTTSService
# ─── Constants ──────────────────────────────────────────────────
START_PROMPT = "Start Call System Prompt"

View file

@ -32,6 +32,12 @@ import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.frames.frames import LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
@ -42,6 +48,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.turns.user_mute import (
@ -50,13 +57,6 @@ from pipecat.turns.user_mute import (
)
from pipecat.utils.enums import EndTaskReason
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.tests import MockLLMService, MockTTSService
async def create_test_pipeline_with_failing_transport(
workflow: WorkflowGraph,

View file

@ -1,6 +1,7 @@
"""Tests for LLM behavior when calling an unregistered function."""
import pytest
from pipecat.frames.frames import (
FunctionCallInProgressFrame,
FunctionCallResultFrame,
@ -12,7 +13,6 @@ from pipecat.frames.frames import (
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.tests import MockLLMService, run_test

View file

@ -13,6 +13,9 @@ import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
Frame,
@ -32,6 +35,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMUserAggregatorParams,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.turns.user_mute import (
@ -43,10 +47,6 @@ from pipecat.turns.user_stop import ExternalUserTurnStopStrategy
from pipecat.turns.user_turn_strategies import UserTurnStrategies
from pipecat.utils.time import time_now_iso8601
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from pipecat.tests import MockLLMService, MockTTSService
class UserSpeechInjector(FrameProcessor):
"""Processor that injects user speaking frames after the bot finishes speaking.

View file

@ -15,6 +15,12 @@ from typing import List
from unittest.mock import AsyncMock, patch
import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
@ -35,6 +41,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMUserAggregatorParams,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests import MockLLMService, MockTTSService
from pipecat.tests.mock_transport import MockTransport
from pipecat.transports.base_transport import TransportParams
from pipecat.turns.user_mute import (
@ -44,13 +51,6 @@ from pipecat.turns.user_mute import (
from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies
from pipecat.utils.time import time_now_iso8601
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.pipecat_engine_variable_extractor import (
VariableExtractionManager,
)
from api.services.workflow.workflow import WorkflowGraph
from pipecat.tests import MockLLMService, MockTTSService
class BotSpeakingObserverProcessor(FrameProcessor):
"""Observer that records mute status when bot speaking events flow upstream.

View file

@ -8,6 +8,7 @@ incoming speech as CONVERSATION or VOICEMAIL and how the main LLM responds.
import asyncio
import pytest
from pipecat.extensions.voicemail.voicemail_detector import VoicemailDetector
from pipecat.frames.frames import (
EndTaskFrame,
@ -26,6 +27,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMUserAggregatorParams,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests import MockLLMService
from pipecat.turns.user_start import (
TranscriptionUserTurnStartStrategy,
VADUserTurnStartStrategy,
@ -36,8 +38,6 @@ from pipecat.turns.user_stop import (
from pipecat.turns.user_turn_strategies import UserTurnStrategies
from pipecat.utils.time import time_now_iso8601
from pipecat.tests import MockLLMService
class FrameInjector(FrameProcessor):
"""Simple processor that can inject frames into the pipeline."""

File diff suppressed because one or more lines are too long

@ -1 +1 @@
Subproject commit 95f03dc647d60f505057db033161f609d9edf79d
Subproject commit 05e06e14c317841b68d35f70055882cd5e24623d

View file

@ -1,5 +1,7 @@
#!/bin/sh -e
#!/usr/bin/env bash
set -euo pipefail
ruff check api --select I --select F401 --fix
ruff format api
(cd ui && npm run fix-lint)

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.YApLaGcbbM
# timestamp: 2026-05-04T09:31:31+00:00
# filename: dograh-openapi-XXXXXX.json.r9ocBCl93O
# timestamp: 2026-05-05T13:22:04+00:00
from __future__ import annotations

View file

@ -1,7 +1,7 @@
"use client";
import { format } from 'date-fns';
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
import { AlertCircle, AlertTriangle, ArrowLeft, CalendarIcon, Check, Clock, Download, Info, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -381,6 +381,38 @@ export default function CampaignDetailPage() {
const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state);
// Newest entries first. The backend appends chronologically; the UI is more
// useful when the most recent failure / pause is at the top.
const sortedLogs = (campaign?.logs ?? []).slice().reverse();
const getLogIcon = (level: string) => {
switch (level) {
case 'error':
return <AlertCircle className="h-4 w-4 text-destructive" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
default:
return <Info className="h-4 w-4 text-blue-500" />;
}
};
const getLogBadgeVariant = (level: string): 'destructive' | 'secondary' | 'outline' => {
switch (level) {
case 'error':
return 'destructive';
case 'warning':
return 'outline';
default:
return 'secondary';
}
};
const formatLogTimestamp = (ts: string) => {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleString();
};
// Render action button based on state
const renderActionButton = () => {
if (!campaign || isExecutingAction) return null;
@ -796,6 +828,56 @@ export default function CampaignDetailPage() {
</CardContent>
</Card>
{/* Activity Log */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Activity Log</CardTitle>
<CardDescription>
Recent state transitions and failures. Newest first.
</CardDescription>
</CardHeader>
<CardContent>
{sortedLogs.length === 0 ? (
<p className="text-sm text-muted-foreground">No events recorded yet.</p>
) : (
<ul className="space-y-3">
{sortedLogs.map((entry, idx) => (
<li
key={`${entry.ts}-${idx}`}
className="flex gap-3 border-b last:border-b-0 pb-3 last:pb-0"
>
<div className="mt-0.5">{getLogIcon(entry.level)}</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getLogBadgeVariant(entry.level)} className="text-xs">
{entry.level}
</Badge>
<code className="text-xs text-muted-foreground">
{entry.event}
</code>
<span className="text-xs text-muted-foreground">
{formatLogTimestamp(entry.ts)}
</span>
</div>
<p className="text-sm mt-1 break-words">{entry.message}</p>
{entry.details && Object.keys(entry.details).length > 0 && (
<details className="mt-1.5">
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
Details
</summary>
<pre className="mt-1.5 text-xs bg-muted rounded p-2 overflow-x-auto whitespace-pre-wrap break-words">
{JSON.stringify(entry.details, null, 2)}
</pre>
</details>
)}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* Workflow Runs */}
<CampaignRuns
campaignId={campaignId}

View file

@ -8,9 +8,10 @@ import { useEffect, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import {
getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet,
initiateCallApiV1TelephonyInitiateCallPost
initiateCallApiV1TelephonyInitiateCallPost,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
} from '@/client/sdk.gen';
import type { TelephonyConfigurationListItem } from '@/client/types.gen';
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -22,6 +23,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUserConfig } from "@/context/UserConfigContext";
interface PhoneCallDialogProps {
@ -47,6 +56,8 @@ export const PhoneCallDialog = ({
const [checkingConfig, setCheckingConfig] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
// Check telephony configuration when dialog opens
useEffect(() => {
@ -55,16 +66,25 @@ export const PhoneCallDialog = ({
setCheckingConfig(true);
try {
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({});
const configResponse = await listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet({});
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix && !configResponse.data?.ari && !configResponse.data?.telnyx && !configResponse.data?.plivo)) {
const configurations = configResponse.data?.configurations ?? [];
if (configResponse.error || configurations.length === 0) {
setNeedsConfiguration(true);
setTelephonyConfigs([]);
setSelectedConfigId("");
} else {
setNeedsConfiguration(false);
setTelephonyConfigs(configurations);
const defaultConfig =
configurations.find((c) => c.is_default_outbound) ?? configurations[0];
setSelectedConfigId(String(defaultConfig.id));
}
} catch (err) {
console.error("Failed to check telephony config:", err);
setNeedsConfiguration(false);
setTelephonyConfigs([]);
setSelectedConfigId("");
} finally {
setCheckingConfig(false);
}
@ -80,6 +100,8 @@ export const PhoneCallDialog = ({
setCallSuccessMsg(null);
setCallLoading(false);
setNeedsConfiguration(null);
setTelephonyConfigs([]);
setSelectedConfigId("");
}
}, [open]);
@ -124,7 +146,8 @@ export const PhoneCallDialog = ({
const response = await initiateCallApiV1TelephonyInitiateCallPost({
body: {
workflow_id: workflowId,
phone_number: phoneNumber
phone_number: phoneNumber,
telephony_configuration_id: selectedConfigId ? Number(selectedConfigId) : null,
},
});
@ -189,6 +212,24 @@ export const PhoneCallDialog = ({
Enter the phone number or SIP endpoint to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
{telephonyConfigs.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="telephony-config">Telephony configuration</Label>
<Select value={selectedConfigId} onValueChange={setSelectedConfigId}>
<SelectTrigger id="telephony-config" className="w-full">
<SelectValue placeholder="Select a configuration" />
</SelectTrigger>
<SelectContent>
{telephonyConfigs.map((config) => (
<SelectItem key={config.id} value={String(config.id)}>
{config.name} ({config.provider})
{config.is_default_outbound ? " — default" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{sipMode ? (
<Input
value={phoneNumber}

File diff suppressed because one or more lines are too long

View file

@ -346,6 +346,39 @@ export type CampaignDefaultsResponse = {
last_campaign_settings?: LastCampaignSettingsResponse | null;
};
/**
* CampaignLogEntryResponse
*
* A single timestamped entry from the campaign's append-only log.
*
* Surfaced in the UI so operators can see why a campaign moved to
* paused / failed without digging through server logs.
*/
export type CampaignLogEntryResponse = {
/**
* Ts
*/
ts: string;
/**
* Level
*/
level: string;
/**
* Event
*/
event: string;
/**
* Message
*/
message: string;
/**
* Details
*/
details?: {
[key: string]: unknown;
} | null;
};
/**
* CampaignProgressResponse
*/
@ -481,6 +514,10 @@ export type CampaignResponse = {
* Telephony Configuration Name
*/
telephony_configuration_name?: string | null;
/**
* Logs
*/
logs?: Array<CampaignLogEntryResponse>;
};
/**