Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ var solutionLocation = empty(location) ? resourceGroup().location : location
azd: {
type: 'location'
usageName: [
'OpenAI.GlobalStandard.GPT5.1, 500'
'OpenAI.GlobalStandard.gpt-5.1, 500'
]
}
})
Expand Down Expand Up @@ -69,11 +69,11 @@ param aiDeploymentType string = 'GlobalStandard'

@minLength(1)
@description('Optional. Name of the AI model to deploy. Recommend using GPT5.1. Defaults to GPT5.1.')
param aiModelName string = 'GPT5.1'
param aiModelName string = 'gpt-5.1'

@minLength(1)
@description('Optional. Version of AI model. Review available version numbers per model before setting. Defaults to 2025-04-16.')
param aiModelVersion string = '2025-04-16'
param aiModelVersion string = '2025-11-13'

@description('Optional. AI model deployment token capacity. Lower this if initial provisioning fails due to capacity. Defaults to 50K tokens per minute to improve regional success rate.')
param aiModelCapacity int = 500
Expand Down Expand Up @@ -899,7 +899,7 @@ module appConfiguration 'br/public:avm/res/app-configuration/configuration-store
}
{
name: 'AZURE_OPENAI_API_VERSION'
value: '2025-01-01-preview'
value: '2025-03-01-preview'
}
{
name: 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME'
Expand Down Expand Up @@ -1065,6 +1065,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.

var backendContainerPort = 80
var backendContainerAppName = take('ca-backend-api-${solutionSuffix}', 32)
var processorContainerAppName = take('ca-processor-${solutionSuffix}', 32)
module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = {
name: take('avm.res.app.container-app.${backendContainerAppName}', 64)
#disable-next-line no-unnecessary-dependson
Expand Down Expand Up @@ -1092,6 +1093,11 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = {
name: 'AZURE_CLIENT_ID'
value: appIdentity.outputs.clientId
}
{
name: 'PROCESSOR_CONTROL_URL'
// Internal ingress FQDN format: https://<app-name>.internal.<environment-default-domain>
value: 'https://${processorContainerAppName}.internal.${containerAppsEnvironment.outputs.defaultDomain}'
}
],
enableMonitoring
? [
Expand Down Expand Up @@ -1211,7 +1217,6 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = {
}
}

var processorContainerAppName = take('ca-processor-${solutionSuffix}', 32)
module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
name: take('avm.res.app.container-app.${processorContainerAppName}', 64)
#disable-next-line no-unnecessary-dependson
Expand Down Expand Up @@ -1247,6 +1252,14 @@ module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
name: 'STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed
value: storageAccount.outputs.name
}
{
name: 'CONTROL_API_ENABLED'
value: '1'
}
{
name: 'CONTROL_API_PORT'
value: '8080'
}
],
enableMonitoring
? [
Expand All @@ -1264,9 +1277,10 @@ module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
}
}
]
ingressTransport: null
disableIngress: true
// Internal ingress required for container-to-container communication
ingressTargetPort: 8080
ingressExternal: false
ingressAllowInsecure: true // Allow HTTP without SSL redirect for internal calls
scaleSettings: {
maxReplicas: enableScalability ? 3 : 1
minReplicas: 1
Expand Down
20 changes: 17 additions & 3 deletions infra/main_custom.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.

var backendContainerPort = 80
var backendContainerAppName = take('ca-backend-api-${solutionSuffix}', 32)
var processorContainerAppName = take('ca-processor-${solutionSuffix}', 32)
module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = {
name: take('avm.res.app.container-app.${backendContainerAppName}', 64)
#disable-next-line no-unnecessary-dependson
Expand Down Expand Up @@ -1070,6 +1071,11 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = {
name: 'AZURE_CLIENT_ID'
value: appIdentity.outputs.clientId
}
{
name: 'PROCESSOR_CONTROL_URL'
// Internal ingress FQDN format: https://<app-name>.internal.<environment-default-domain>
value: 'https://${processorContainerAppName}.internal.${containerAppsEnvironment.outputs.defaultDomain}'
}
],
enableMonitoring
? [
Expand Down Expand Up @@ -1186,7 +1192,6 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = {
}
}

var processorContainerAppName = take('ca-processor-${solutionSuffix}', 32)
module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
name: take('avm.res.app.container-app.${processorContainerAppName}', 64)
#disable-next-line no-unnecessary-dependson
Expand Down Expand Up @@ -1229,6 +1234,14 @@ module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
name: 'STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed
value: storageAccount.outputs.name
}
{
name: 'CONTROL_API_ENABLED'
value: '1'
}
{
name: 'CONTROL_API_PORT'
value: '8080'
}
],
enableMonitoring
? [
Expand All @@ -1246,9 +1259,10 @@ module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
}
}
]
ingressTransport: null
disableIngress: true
// Internal ingress required for container-to-container communication
ingressTargetPort: 8080
ingressExternal: false
ingressAllowInsecure: true // Allow HTTP without SSL redirect for internal calls
scaleSettings: {
maxReplicas: enableScalability ? 3 : 1
minReplicas: 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ class Configuration(_configuration_base, KernelBaseSettings):
default=None, env="APPLICATIONINSIGHTS_CONNECTION_STRING"
)

# Processor Control API configuration
# In Azure Container Apps, apps call each other by name: http://<container-app-name>
# The actual URL is set via PROCESSOR_CONTROL_URL env var from Bicep
processor_control_url: str | None = Field(
default="http://localhost:8080", env="PROCESSOR_CONTROL_URL"
)
processor_control_token: str | None = Field(
default=None, env="PROCESSOR_CONTROL_TOKEN"
)


class _envConfiguration(_configuration_base):
"""
Expand Down
190 changes: 189 additions & 1 deletion src/backend-api/src/app/routers/router_process.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import io
import zipfile
from enum import Enum
from typing import List
from typing import List, Optional
from uuid import uuid4

import httpx
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from libs.base.typed_fastapi import TypedFastAPI
Expand Down Expand Up @@ -37,6 +38,8 @@ class process_router_paths(str, Enum):
START_PROCESSING = "/start-processing"
DELETE_FILE = "/delete-file/{file_name}"
DELETE_PROCESS = "/delete-process/{process_id}"
CANCEL_PROCESS = "/cancel/{process_id}"
CANCEL_STATUS = "/cancel/{process_id}/status"
STATUS = "/status/{process_id}/"
RENDER_STATUS = "/status/{process_id}/render/"
PROCESS_AGENT_ACTIVITIES = "/status/{process_id}/activities"
Expand Down Expand Up @@ -578,3 +581,188 @@ async def get_file_content(
raise HTTPException(
status_code=500, detail=f"Error retrieving file content: {str(e)}"
)


@router.post(process_router_paths.CANCEL_PROCESS, status_code=202)
async def cancel_process(
process_id: str,
request: Request,
reason: Optional[str] = None,
):
"""
Request cancellation of a running process.
This endpoint forwards the kill request to the Processor's Control API.
The processor will observe this request and terminate the running process.
"""
app: TypedFastAPI = request.app
logger_service: ILoggerService = app.app_context.get_service(ILoggerService)

try:
logger_service.log_info(f"Cancel process request for process_id: {process_id}")

# Get authenticated user
authenticated_user = get_authenticated_user(request)
user_id = authenticated_user.user_principal_id

if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Get processor control URL from configuration
config = app.app_context.configuration
processor_url = config.processor_control_url or "http://processor:8080"
processor_token = config.processor_control_token or ""

# Prepare headers for processor control API
headers = {}
if processor_token:
headers["Authorization"] = f"Bearer {processor_token}"

# Build the full URL for the kill endpoint
kill_url = f"{processor_url}/processes/{process_id}/kill"
logger_service.log_info(f"Calling processor kill API at: {kill_url}")

# Forward kill request to Processor Control API
# Note: verify=False is needed for internal ACA communication (self-signed certs)
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
response = await client.post(
kill_url,
json={"reason": reason or f"User {user_id} cancelled from UI"},
headers=headers,
)
logger_service.log_info(f"Processor kill API response: {response.status_code}")

if response.status_code == 401:
logger_service.log_error("Unauthorized access to processor control API")
raise HTTPException(
status_code=502,
detail="Failed to authenticate with processor control API",
)

if response.status_code >= 400:
logger_service.log_error(
f"Processor control API error: {response.status_code} - {response.text}"
)
raise HTTPException(
status_code=502,
detail=f"Processor control API error: {response.text}",
)

result = response.json()

logger_service.log_info(
f"Cancel request sent for process {process_id}, state: {result.get('kill_state', 'unknown')}"
)

return {
"message": "Cancellation request submitted",
"process_id": process_id,
"kill_requested": result.get("kill_requested", True),
"kill_state": result.get("kill_state", "pending"),
"kill_requested_at": result.get("kill_requested_at", ""),
}

except httpx.TimeoutException:
logger_service.log_error(f"Timeout connecting to processor control API")
raise HTTPException(
status_code=504,
detail="Timeout connecting to processor control API",
)
except httpx.ConnectError:
logger_service.log_error(f"Failed to connect to processor control API")
raise HTTPException(
status_code=503,
detail="Processor control API is unavailable",
)
except HTTPException:
raise
except Exception as e:
logger_service.log_error(f"Error in cancel_process: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error cancelling process: {str(e)}"
)


@router.get(process_router_paths.CANCEL_STATUS, status_code=200)
async def get_cancel_status(
process_id: str,
request: Request,
):
"""
Get the cancellation status of a process.
Returns the current kill state from the Processor's Control API.
"""
app: TypedFastAPI = request.app
logger_service: ILoggerService = app.app_context.get_service(ILoggerService)

try:
logger_service.log_info(f"Get cancel status for process_id: {process_id}")

# Get authenticated user
authenticated_user = get_authenticated_user(request)
user_id = authenticated_user.user_principal_id

if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Get processor control URL from configuration
config = app.app_context.configuration
processor_url = config.processor_control_url or "http://processor:8080"
processor_token = config.processor_control_token or ""

# Prepare headers for processor control API
headers = {}
if processor_token:
headers["Authorization"] = f"Bearer {processor_token}"

# Build the full URL for the control status endpoint
control_url = f"{processor_url}/processes/{process_id}/control"
logger_service.log_info(f"Calling processor control API at: {control_url}")

# Get control status from Processor Control API
# Note: verify=False is needed for internal ACA communication (self-signed certs)
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
response = await client.get(
control_url,
headers=headers,
)
logger_service.log_info(f"Processor control API response: {response.status_code}")

if response.status_code == 401:
logger_service.log_error("Unauthorized access to processor control API")
raise HTTPException(
status_code=502,
detail="Failed to authenticate with processor control API",
)

if response.status_code >= 400:
logger_service.log_error(
f"Processor control API error: {response.status_code} - {response.text}"
)
raise HTTPException(
status_code=502,
detail=f"Processor control API error: {response.text}",
)

result = response.json()

return result

except httpx.TimeoutException:
logger_service.log_error(f"Timeout connecting to processor control API")
raise HTTPException(
status_code=504,
detail="Timeout connecting to processor control API",
)
except httpx.ConnectError:
logger_service.log_error(f"Failed to connect to processor control API")
raise HTTPException(
status_code=503,
detail="Processor control API is unavailable",
)
except HTTPException:
raise
except Exception as e:
logger_service.log_error(f"Error in get_cancel_status: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error getting cancel status: {str(e)}"
)
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,15 @@ const ProgressModal: React.FC<ProgressModalProps> = ({
setOpen(false);
};

const handleCancel = () => {
// Trigger onCancel (navigate to landing page) and close modal
const handleCancel = async () => {
console.log('=== ProgressModal handleCancel called ===');
// Trigger onCancel (calls cancel API and navigates to landing page) and close modal
if (onCancel) {
onCancel();
console.log('Calling onCancel callback...');
await onCancel();
console.log('onCancel callback completed');
} else {
console.warn('No onCancel callback provided');
}
setOpen(false);
};
Expand Down
Loading