fix: move draft and template context handling out of create_workflow_run (#560)

* fix: move draft and template context handling out of create_workflow_run

create_workflow_run now only creates
the run with the definition_id and initial_context provided by the caller. Test call paths explicitly resolve draft definitions
and merge template context variables before creating the run, while production/runtime paths bind the published definition
without adding template defaults.

* fix: review comments

* chore: format and minor cleanups

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Sabiha Khan 2026-07-21 13:24:51 +05:30 committed by GitHub
parent 94dce42209
commit 90c5831984
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 657 additions and 72 deletions

View file

@ -28,6 +28,8 @@ async def test_agent_stream_uses_provider_path_param_not_query_param():
user_id=22,
organization_id=33,
template_context_variables={"existing": "context"},
released_definition=SimpleNamespace(id=55, template_context_variables={}),
current_definition=None,
)
workflow_run = SimpleNamespace(id=44)
provider = SimpleNamespace(handle_external_websocket=AsyncMock())
@ -72,10 +74,10 @@ async def test_agent_stream_uses_provider_path_param_not_query_param():
assert create_args[2] == "cloudonix"
assert create_kwargs["organization_id"] == workflow.organization_id
assert create_kwargs["initial_context"] == {
"existing": "context",
"provider": "cloudonix",
"direction": "inbound",
}
assert create_kwargs["definition_id"] == 55
provider.handle_external_websocket.assert_awaited_once()
_, provider_kwargs = provider.handle_external_websocket.await_args
assert provider_kwargs["params"] == {"custom": "value"}
@ -92,6 +94,8 @@ async def test_agent_stream_rejects_when_concurrency_limit_reached():
user_id=22,
organization_id=33,
template_context_variables={},
released_definition=SimpleNamespace(id=55, template_context_variables={}),
current_definition=None,
)
spec = SimpleNamespace(provider_cls=lambda _config: object())

View file

@ -281,7 +281,7 @@ class TestDispatcherThreadsTelephonyConfig:
),
),
):
mock_db.get_workflow_by_id = AsyncMock(return_value=SimpleNamespace(id=1))
mock_db.get_workflow = AsyncMock(return_value=SimpleNamespace(id=1))
mock_db.create_workflow_run = AsyncMock(return_value=workflow_run)
mock_db.update_workflow_run = AsyncMock()
mock_concurrency.bind_workflow_run = AsyncMock()
@ -300,6 +300,11 @@ class TestDispatcherThreadsTelephonyConfig:
)
await dispatcher.dispatch_call(queued_run, campaign, slot)
mock_db.get_workflow.assert_awaited_once_with(
campaign.workflow_id,
organization_id=org_id,
)
# acquire_from_number on rate_limiter must be called with the
# campaign's telephony_configuration_id.
assert mock_rl.acquire_from_number.await_count == 1

View file

@ -31,8 +31,12 @@ def _active_workflow(*, trigger_path: str | None = None):
status="active",
workflow_uuid="workflow-uuid-123",
released_definition=SimpleNamespace(
workflow_json={"nodes": nodes, "edges": []}
id=77,
workflow_json={"nodes": nodes, "edges": []},
template_context_variables={"name": "published"},
),
current_definition=None,
template_context_variables={"name": "workflow"},
)
@ -123,6 +127,9 @@ def test_trigger_route_executes_as_workflow_owner():
assert create_kwargs["initial_context"]["workflow_uuid"] == workflow.workflow_uuid
assert create_kwargs["initial_context"]["api_key_id"] == 7
assert create_kwargs["initial_context"]["api_key_created_by"] == 22
assert create_kwargs["definition_id"] == 77
assert "name" not in create_kwargs["initial_context"]
assert not mock_db.get_draft_version.called
initiate_kwargs = provider.initiate_call.await_args.kwargs
assert initiate_kwargs["workflow_id"] == workflow.id
@ -197,6 +204,148 @@ def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution():
)
assert create_kwargs["initial_context"]["agent_identifier_type"] == "workflow_uuid"
assert "agent_uuid" not in create_kwargs["initial_context"]
assert create_kwargs["definition_id"] == 77
assert "name" not in create_kwargs["initial_context"]
assert not mock_db.get_draft_version.called
def test_trigger_test_route_uses_draft_and_template_context_with_api_override():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow(trigger_path="trigger-uuid-123")
draft = SimpleNamespace(
id=88,
workflow_json=workflow.released_definition.workflow_json,
template_context_variables={"name": "john", "age": 12, "rank": 2},
)
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.public_agent.db_client") as mock_db,
patch("api.routes.public_agent.call_concurrency") as mock_concurrency,
patch(
"api.routes.public_agent.authorize_workflow_run_start",
new=quota_mock,
),
patch(
"api.routes.public_agent.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.public_agent.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
slot = object()
mock_concurrency.acquire_org_slot = AsyncMock(return_value=slot)
mock_concurrency.bind_workflow_run = AsyncMock()
mock_concurrency.release_workflow_run_slot = AsyncMock()
mock_concurrency.release_slot = AsyncMock()
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=7, organization_id=11, created_by=22)
)
mock_db.get_agent_trigger_by_path = AsyncMock(
return_value=SimpleNamespace(
workflow_id=workflow.id,
organization_id=11,
state="active",
)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_draft_version = AsyncMock(return_value=draft)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501))
response = client.post(
"/public/agent/test/trigger-uuid-123",
headers={"X-API-Key": "test-api-key"},
json={
"phone_number": "+15551234567",
"initial_context": {"name": "tom", "age": 10},
},
)
assert response.status_code == 200
assert mock_db.get_draft_version.await_count == 2
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["definition_id"] == draft.id
assert create_kwargs["initial_context"]["name"] == "tom"
assert create_kwargs["initial_context"]["age"] == 10
assert create_kwargs["initial_context"]["rank"] == 2
assert create_kwargs["initial_context"]["trigger_mode"] == "test"
def test_workflow_uuid_test_route_uses_draft_and_template_context():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow()
draft = SimpleNamespace(
id=88,
workflow_json={"nodes": [], "edges": []},
template_context_variables={"name": "john", "age": 12, "rank": 2},
)
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.public_agent.db_client") as mock_db,
patch("api.routes.public_agent.call_concurrency") as mock_concurrency,
patch(
"api.routes.public_agent.authorize_workflow_run_start",
new=quota_mock,
),
patch(
"api.routes.public_agent.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.public_agent.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
slot = object()
mock_concurrency.acquire_org_slot = AsyncMock(return_value=slot)
mock_concurrency.bind_workflow_run = AsyncMock()
mock_concurrency.release_workflow_run_slot = AsyncMock()
mock_concurrency.release_slot = AsyncMock()
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=7, organization_id=11, created_by=22)
)
mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow)
mock_db.get_draft_version = AsyncMock(return_value=draft)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501))
response = client.post(
f"/public/agent/test/workflow/{workflow.workflow_uuid}",
headers={"X-API-Key": "test-api-key"},
json={
"phone_number": "+15551234567",
"initial_context": {"name": "tom"},
},
)
assert response.status_code == 200
assert mock_db.get_draft_version.await_count == 2
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["definition_id"] == draft.id
assert create_kwargs["initial_context"]["name"] == "tom"
assert create_kwargs["initial_context"]["age"] == 12
assert create_kwargs["initial_context"]["rank"] == 2
assert create_kwargs["initial_context"]["trigger_mode"] == "test"
def test_trigger_route_rejects_when_concurrency_limit_reached():

View file

@ -89,6 +89,14 @@ def _patch_db(monkeypatch):
async def _create_workflow_run(**_kwargs):
return SimpleNamespace(id=123)
async def _get_workflow(*_args, **_kwargs):
return SimpleNamespace(
id=1,
released_definition=SimpleNamespace(id=55, template_context_variables={}),
current_definition=None,
template_context_variables={},
)
async def _noop(*_args, **_kwargs):
return None
@ -108,6 +116,10 @@ def _patch_db(monkeypatch):
"api.routes.public_embed.db_client.create_workflow_run",
_create_workflow_run,
)
monkeypatch.setattr(
"api.routes.public_embed.db_client.get_workflow",
_get_workflow,
)
monkeypatch.setattr(
"api.routes.public_embed.db_client.create_embed_session",
_noop,

View file

@ -28,6 +28,11 @@ def _workflow(*, workflow_id: int = 33, user_id: int = 99):
user_id=user_id,
organization_id=11,
template_context_variables={"template_key": "template-value"},
released_definition=SimpleNamespace(
id=77,
template_context_variables={"template_key": "template-value"},
),
current_definition=None,
)
@ -84,6 +89,7 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_draft_version = AsyncMock(return_value=None)
mock_db.create_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,
@ -113,6 +119,7 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
assert create_args[1] == workflow.id
assert create_kwargs["user_id"] == workflow.user_id
assert create_kwargs["organization_id"] == workflow.organization_id
assert create_kwargs["definition_id"] == 77
assert create_kwargs["initial_context"]["template_key"] == "template-value"
mock_concurrency.acquire_org_slot.assert_awaited_once_with(
workflow.organization_id,
@ -132,6 +139,79 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
mock_db.get_user_configurations.assert_not_called()
def test_initiate_call_uses_draft_template_context_with_provider_overrides():
app = _make_test_app()
client = TestClient(app)
workflow = _workflow()
draft = SimpleNamespace(
id=88,
template_context_variables={
"phone_number": "template-phone",
"called_number": "template-called",
"provider": "template-provider",
"draft_only": "kept",
},
)
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.telephony.db_client") as mock_db,
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
patch(
"api.routes.telephony.authorize_workflow_run_start",
new=quota_mock,
),
patch(
"api.routes.telephony.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.telephony.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
slot = object()
mock_concurrency.acquire_org_slot = AsyncMock(return_value=slot)
mock_concurrency.bind_workflow_run = AsyncMock()
mock_concurrency.release_slot = AsyncMock()
mock_concurrency.release_workflow_run_slot = AsyncMock()
mock_db.get_user_configurations = AsyncMock(
return_value=SimpleNamespace(test_phone_number=None)
)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_draft_version = AsyncMock(return_value=draft)
mock_db.create_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,
name="WR-TEL-OUT-00000001",
initial_context={},
)
)
mock_db.update_workflow_run = AsyncMock()
response = client.post(
"/telephony/initiate-call",
json={"workflow_id": workflow.id, "phone_number": "+15551234567"},
)
assert response.status_code == 200
mock_db.get_draft_version.assert_awaited_once_with(workflow.id)
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["definition_id"] == draft.id
assert create_kwargs["initial_context"]["phone_number"] == "+15551234567"
assert create_kwargs["initial_context"]["called_number"] == "+15551234567"
assert create_kwargs["initial_context"]["provider"] == provider.PROVIDER_NAME
assert create_kwargs["initial_context"]["draft_only"] == "kept"
def test_initiate_call_uses_organization_preference_phone_number():
app = _make_test_app()
client = TestClient(app)
@ -173,6 +253,7 @@ def test_initiate_call_uses_organization_preference_phone_number():
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_draft_version = AsyncMock(return_value=None)
mock_db.create_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,

View file

@ -1,5 +1,6 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
@ -82,3 +83,51 @@ def test_create_workflow_rejects_duplicate_api_triggers_before_db_write():
assert detail["errors"][0]["kind"] == "workflow"
assert "at most one API Trigger" in detail["errors"][0]["message"]
assert mock_db.mock_calls == []
def test_create_workflow_run_uses_draft_and_template_context():
app = _make_test_app()
client = TestClient(app)
workflow = SimpleNamespace(
id=33,
released_definition=SimpleNamespace(
id=77,
template_context_variables={"name": "published"},
),
current_definition=None,
template_context_variables={"name": "workflow"},
)
draft = SimpleNamespace(
id=88,
template_context_variables={"name": "draft", "draft_only": "kept"},
)
run = SimpleNamespace(
id=501,
workflow_id=workflow.id,
name="WR-test",
mode="smallwebrtc",
created_at=datetime.now(UTC),
definition_id=draft.id,
initial_context={"name": "draft", "draft_only": "kept"},
gathered_context={},
)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_draft_version = AsyncMock(return_value=draft)
mock_db.create_workflow_run = AsyncMock(return_value=run)
response = client.post(
f"/workflow/{workflow.id}/runs",
json={"name": "WR-test", "mode": "smallwebrtc"},
)
assert response.status_code == 200
mock_db.get_draft_version.assert_awaited_once_with(workflow.id)
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["definition_id"] == draft.id
assert create_kwargs["initial_context"] == {
"name": "draft",
"draft_only": "kept",
}

View file

@ -0,0 +1,146 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from api.services.workflow.run_creation import prepare_workflow_run_inputs
def _workflow():
return SimpleNamespace(
id=33,
template_context_variables={"name": "workflow", "workflow_only": "kept"},
released_definition=SimpleNamespace(
id=77,
template_context_variables={
"name": "published",
"published_only": "kept",
},
),
current_definition=None,
)
@pytest.mark.asyncio
async def test_prepare_inputs_uses_draft_and_merges_template_context():
draft = SimpleNamespace(
id=88,
template_context_variables={"name": "draft", "draft_only": "kept"},
)
workflow_client = SimpleNamespace(get_draft_version=AsyncMock(return_value=draft))
run_inputs = await prepare_workflow_run_inputs(
workflow_client,
_workflow(),
initial_context={"name": "explicit", "explicit_only": "kept"},
use_draft=True,
include_template_context=True,
)
assert run_inputs.definition_id == 88
assert run_inputs.initial_context == {
"name": "explicit",
"draft_only": "kept",
"explicit_only": "kept",
}
workflow_client.get_draft_version.assert_awaited_once_with(33)
@pytest.mark.asyncio
async def test_prepare_inputs_does_not_check_draft_or_merge_templates_by_default():
workflow_client = SimpleNamespace(get_draft_version=AsyncMock())
run_inputs = await prepare_workflow_run_inputs(
workflow_client,
_workflow(),
initial_context={"name": "explicit"},
)
assert run_inputs.definition_id == 77
assert run_inputs.initial_context == {"name": "explicit"}
workflow_client.get_draft_version.assert_not_awaited()
@pytest.mark.asyncio
async def test_prepare_inputs_falls_back_to_published_when_draft_missing():
workflow_client = SimpleNamespace(get_draft_version=AsyncMock(return_value=None))
run_inputs = await prepare_workflow_run_inputs(
workflow_client,
_workflow(),
initial_context={"name": "explicit"},
use_draft=True,
include_template_context=True,
)
assert run_inputs.definition_id == 77
assert run_inputs.initial_context == {
"name": "explicit",
"published_only": "kept",
}
workflow_client.get_draft_version.assert_awaited_once_with(33)
@pytest.mark.asyncio
async def test_prepare_inputs_uses_current_definition_when_released_missing():
workflow = _workflow()
workflow.released_definition = None
workflow.current_definition = SimpleNamespace(
id=66,
template_context_variables={"name": "current", "current_only": "kept"},
)
workflow_client = SimpleNamespace(get_draft_version=AsyncMock())
run_inputs = await prepare_workflow_run_inputs(
workflow_client,
workflow,
include_template_context=True,
)
assert run_inputs.definition_id == 66
assert run_inputs.initial_context == {
"name": "current",
"current_only": "kept",
}
workflow_client.get_draft_version.assert_not_awaited()
@pytest.mark.asyncio
async def test_prepare_inputs_falls_back_to_workflow_template_context_when_definition_context_is_none():
workflow = _workflow()
workflow.released_definition = SimpleNamespace(
id=77,
template_context_variables=None,
)
workflow_client = SimpleNamespace(get_draft_version=AsyncMock())
run_inputs = await prepare_workflow_run_inputs(
workflow_client,
workflow,
include_template_context=True,
)
assert run_inputs.definition_id == 77
assert run_inputs.initial_context == {
"name": "workflow",
"workflow_only": "kept",
}
@pytest.mark.asyncio
async def test_prepare_inputs_respects_empty_definition_template_context():
workflow = _workflow()
workflow.released_definition = SimpleNamespace(
id=77,
template_context_variables={},
)
workflow_client = SimpleNamespace(get_draft_version=AsyncMock())
run_inputs = await prepare_workflow_run_inputs(
workflow_client,
workflow,
include_template_context=True,
)
assert run_inputs.definition_id == 77
assert run_inputs.initial_context == {}

View file

@ -198,6 +198,11 @@ async def test_text_chat_session_creation_executes_initial_assistant_turn(
workflow_definition=workflow_definition,
suffix="bootstrap",
)
draft = await db_session.save_workflow_draft(
workflow_id=workflow.id,
workflow_definition=workflow_definition,
template_context_variables={"name": "draft", "draft_only": "kept"},
)
llm = MockLLMService(
mock_steps=[
@ -219,7 +224,7 @@ async def test_text_chat_session_creation_executes_initial_assistant_turn(
):
create_response = await client.post(
f"/api/v1/workflow/{workflow.id}/text-chat/sessions",
json={},
json={"initial_context": {"name": "explicit"}},
)
assert create_response.status_code == 200
created = create_response.json()
@ -242,6 +247,11 @@ async def test_text_chat_session_creation_executes_initial_assistant_turn(
assert "Start" in (created["gathered_context"] or {}).get("nodes_visited", [])
workflow_run = await db_session.get_workflow_run_by_id(created["workflow_run_id"])
assert workflow_run is not None
assert workflow_run.definition_id == draft.id
assert workflow_run.initial_context == {
"name": "explicit",
"draft_only": "kept",
}
assert "call_duration_seconds" in workflow_run.usage_info
assert _log_texts(run_payload["logs"], "rtf-bot-text") == [
"Hello from the workflow tester."

View file

@ -574,21 +574,25 @@ class TestRunDefinitionBinding:
workflow_definition=GRAPH_V2,
)
versions = await db_session.get_workflow_versions(workflow.id)
published = next(v for v in versions if v.status == "published")
# Create a run (simulating campaign dispatch)
run = await db_session.create_workflow_run(
name="Campaign Run",
workflow_id=workflow.id,
mode="webrtc",
user_id=user.id,
definition_id=published.id,
)
# Run should be bound to the published V1, not the draft V2
versions = await db_session.get_workflow_versions(workflow.id)
published = next(v for v in versions if v.status == "published")
# Run should be bound to the caller-selected published V1.
assert run.definition_id == published.id
async def test_test_run_uses_draft_if_exists(self, db_session, workflow_with_v1):
"""A test/phone call should use the draft version for pre-publish testing."""
async def test_run_uses_caller_supplied_draft_definition(
self, db_session, workflow_with_v1
):
"""Test/phone callers bind drafts before creating the run."""
workflow, user = workflow_with_v1
draft = await db_session.save_workflow_draft(
@ -602,15 +606,15 @@ class TestRunDefinitionBinding:
workflow_id=workflow.id,
mode="webrtc", # test mode
user_id=user.id,
use_draft=True,
definition_id=draft.id,
)
assert run.definition_id == draft.id
async def test_run_initial_context_merges_with_template_context(
async def test_run_initial_context_is_stored_as_provided(
self, db_session, workflow_with_v1
):
"""Explicit run context should augment template context, not replace it."""
"""Template context merging is handled before calling the DB client."""
workflow, user = workflow_with_v1
await db_session.save_workflow_draft(
workflow_id=workflow.id,
@ -634,6 +638,37 @@ class TestRunDefinitionBinding:
assert run.initial_context == {
"company_name": "Override Co",
"default_only": "kept",
"provider": "smallwebrtc",
}
async def test_run_rejects_definition_from_another_workflow(
self, db_session, org_and_user
):
"""The DB client must not bind a run to another workflow's definition."""
org, user = org_and_user
workflow_a = await db_session.create_workflow(
name="Workflow A",
workflow_definition=GRAPH_V1,
user_id=user.id,
organization_id=org.id,
)
workflow_b = await db_session.create_workflow(
name="Workflow B",
workflow_definition=GRAPH_V2,
user_id=user.id,
organization_id=org.id,
)
workflow_b_versions = await db_session.get_workflow_versions(workflow_b.id)
workflow_b_definition = next(
v for v in workflow_b_versions if v.status == "published"
)
with pytest.raises(ValueError, match="does not belong"):
await db_session.create_workflow_run(
name="Bad Run",
workflow_id=workflow_a.id,
mode="smallwebrtc",
user_id=user.id,
organization_id=org.id,
definition_id=workflow_b_definition.id,
)