feat: agent versioning and model configurations override (#227)

* feat: add tests and migrations

* feat: workflow versioning among published and draft

* feat: add a new settings page to simplify workflow detail page

* fix: fix tsclient generation
This commit is contained in:
Abhishek 2026-04-08 19:20:31 +05:30 committed by GitHub
parent f5fa9ce717
commit 38d1d928b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 10158 additions and 3131 deletions

View file

@ -15,10 +15,12 @@ from api.db.workflow_template_client import WorkflowTemplateClient
from api.enums import CallType
from api.schemas.workflow import WorkflowRunResponseSchema
from api.services.auth.depends import get_user
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.masking import (
mask_workflow_definition,
merge_workflow_api_keys,
)
from api.services.configuration.resolve import resolve_effective_config
from api.services.mps_service_key_client import mps_service_key_client
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.duplicate import duplicate_workflow
@ -104,6 +106,8 @@ class WorkflowResponse(BaseModel):
call_disposition_codes: CallDispositionCodes | None = None
total_runs: int | None = None
workflow_configurations: dict | None = None
version_number: int | None = None
version_status: str | None = None
class WorkflowListResponse(BaseModel):
@ -149,6 +153,17 @@ class UpdateWorkflowRequest(BaseModel):
workflow_configurations: dict | None = None
class WorkflowVersionResponse(BaseModel):
id: int
version_number: int
status: str
created_at: datetime
published_at: datetime | None = None
workflow_json: dict
workflow_configurations: dict | None = None
template_context_variables: dict | None = None
class UpdateWorkflowStatusRequest(BaseModel):
status: str # "active" or "archived"
@ -200,8 +215,11 @@ async def validate_workflow(
errors: list[WorkflowError] = []
# Get workflow definition from WorkflowDefinition table, fallback to workflow_definition field
workflow_definition = workflow.workflow_definition_with_fallback
# Validate draft if it exists (user is editing), else validate published
draft = await db_client.get_draft_version(workflow_id)
workflow_definition = (
draft.workflow_json if draft else workflow.released_definition.workflow_json
)
# ----------- DTO Validation ------------
dto: Optional[ReactFlowDTO] = None
@ -282,9 +300,7 @@ async def create_workflow(
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.workflow_definition_with_fallback
),
"workflow_definition": mask_workflow_definition(request.workflow_definition),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
@ -362,9 +378,7 @@ async def create_workflow_from_template(
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.workflow_definition_with_fallback
),
"workflow_definition": mask_workflow_definition(workflow_def),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
@ -461,7 +475,11 @@ async def get_workflow(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""Get a single workflow by ID"""
"""Get a single workflow by ID.
If a draft version exists, returns the draft content for editing.
Otherwise returns the published version's content.
"""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
@ -470,21 +488,123 @@ async def get_workflow(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
# Check for draft — editor should show draft content if it exists
draft = await db_client.get_draft_version(workflow_id)
if draft:
workflow_def = draft.workflow_json
workflow_configs = draft.workflow_configurations
template_vars = draft.template_context_variables
else:
published = workflow.released_definition
workflow_def = published.workflow_json
workflow_configs = published.workflow_configurations
template_vars = published.template_context_variables
active_def = draft or workflow.released_definition
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.workflow_definition_with_fallback
),
"workflow_definition": mask_workflow_definition(workflow_def),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"template_context_variables": template_vars,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"workflow_configurations": workflow_configs,
"version_number": active_def.version_number if active_def else None,
"version_status": active_def.status if active_def else None,
}
@router.get("/{workflow_id}/versions")
async def get_workflow_versions(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> list[WorkflowVersionResponse]:
"""List all versions for a workflow, newest first."""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
versions = await db_client.get_workflow_versions(workflow_id)
return [
WorkflowVersionResponse(
id=v.id,
version_number=v.version_number,
status=v.status,
created_at=v.created_at,
published_at=v.published_at,
workflow_json=mask_workflow_definition(v.workflow_json),
workflow_configurations=v.workflow_configurations,
template_context_variables=v.template_context_variables,
)
for v in versions
if v.version_number is not None
]
@router.post("/{workflow_id}/publish")
async def publish_workflow(
workflow_id: int,
user: UserModel = Depends(get_user),
):
"""Publish the current draft version of a workflow."""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
try:
published = await db_client.publish_workflow_draft(workflow_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": published.id,
"version_number": published.version_number,
"status": published.status,
"published_at": published.published_at,
}
@router.post("/{workflow_id}/create-draft")
async def create_workflow_draft(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> WorkflowVersionResponse:
"""Create a draft version from the current published version.
If a draft already exists, returns the existing draft.
"""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
draft = await db_client.save_workflow_draft(workflow_id)
return WorkflowVersionResponse(
id=draft.id,
version_number=draft.version_number,
status=draft.status,
created_at=draft.created_at,
published_at=draft.published_at,
workflow_json=mask_workflow_definition(draft.workflow_json),
workflow_configurations=draft.workflow_configurations,
template_context_variables=draft.template_context_variables,
)
@router.get("/summary")
async def get_workflows_summary(
user: UserModel = Depends(get_user),
@ -528,7 +648,7 @@ async def update_workflow_status(
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.workflow_definition_with_fallback
workflow.released_definition.workflow_json
),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
@ -569,11 +689,37 @@ async def update_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if existing_workflow:
# Merge against what the user was editing (draft or published)
existing_draft = await db_client.get_draft_version(workflow_id)
existing_def = (
existing_draft.workflow_json
if existing_draft
else existing_workflow.released_definition.workflow_json
)
workflow_definition = merge_workflow_api_keys(
workflow_definition,
existing_workflow.workflow_definition_with_fallback,
existing_def,
)
# Validate model_overrides: resolve onto global config, then
# run the same validator used by the user-configurations endpoint.
if request.workflow_configurations and request.workflow_configurations.get(
"model_overrides"
):
user_config = await db_client.get_user_configurations(user.id)
try:
effective = resolve_effective_config(
user_config,
request.workflow_configurations["model_overrides"],
)
await UserConfigurationValidator().validate(
effective,
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
workflow = await db_client.update_workflow(
workflow_id=workflow_id,
name=request.name,
@ -592,19 +738,35 @@ async def update_workflow(
trigger_paths=trigger_paths,
)
# Return draft content if one exists (save creates a draft)
draft = await db_client.get_draft_version(workflow_id)
if draft:
workflow_def = draft.workflow_json
workflow_configs = draft.workflow_configurations
template_vars = draft.template_context_variables
else:
published = workflow.released_definition
workflow_def = published.workflow_json
workflow_configs = published.workflow_configurations
template_vars = published.template_context_variables
# Include version info from the active definition (draft or published)
active_def = draft or workflow.released_definition
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.workflow_definition_with_fallback
),
"workflow_definition": mask_workflow_definition(workflow_def),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"template_context_variables": template_vars,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"workflow_configurations": workflow_configs,
"version_number": active_def.version_number if active_def else None,
"version_status": active_def.status if active_def else None,
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
@ -629,7 +791,7 @@ async def duplicate_workflow_endpoint(
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.workflow_definition_with_fallback
workflow.released_definition.workflow_json
),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
@ -658,7 +820,7 @@ async def create_workflow_run(
user: The user to create the workflow run for
"""
run = await db_client.create_workflow_run(
request.name, workflow_id, request.mode, user.id
request.name, workflow_id, request.mode, user.id, use_draft=True
)
return {
"id": run.id,
@ -862,9 +1024,7 @@ async def duplicate_workflow_template(
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": mask_workflow_definition(
workflow.workflow_definition_with_fallback
),
"workflow_definition": mask_workflow_definition(workflow_def),
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,