Skip to content

Commit 6d7715b

Browse files
authored
fix: follow deployment boundary rules and fail fast (wxo) (#12539)
* feat(wxo): filter configs to key_value_creds, surface type and environment, harden mapper contract Service layer (service.py): - Filter list_configs to only return connections with security_scheme == "key_value_creds" in both tenant and deployment scopes - Surface `environment` field alongside `type` in provider_data for config list items - Normalize security_scheme via _normalize_optional_text to handle SDK enum/tuple quirks - Extract _warn_if_expected_ids_missing helper to deduplicate staleness warnings - Remove defensive isinstance checks on provider responses (trust the provider) - Replace conflicting-binding error with last-write-wins (app_to_connection_id.update) - Deduplicate connection IDs before calling get_drafts_by_ids Mapper layer (mapper.py): - Fail fast with HTTP 500 if config list item is missing a truthy `type` - Conditionally include `environment` in shaped config list payload Payloads (payloads.py): - Add `environment: str | None` field to WatsonxApiConfigListItem with normalizing validator Tests: - Add test for deployment-scope key_value_creds filtering (mixed security schemes) - Add test for tenant-scope key_value_creds filtering (oauth2 excluded) - Add test for environment metadata passthrough in mapper and service - Add tests for provider failure paths (tenant list failure, None response, tool fetch failure) - Add tests for edge cases (latest-binding-wins, skip enrichment with no connections, malformed detailed connection) - Update mapper tests to assert fail-fast on missing type - Update existing fixtures to include security_scheme where required by new filtering * refactor(deployments): move flow-version tool_name into provider_data Move provider tool_name from a top-level flow-version response field into provider_data, aligning API ownership boundaries for provider-originated non-persisted fields. - Remove top-level tool_name from DeploymentFlowVersionListItem - Add tool_name to WatsonxApiDeploymentFlowVersionItemData with normalization - Update WXO mapper to shape tool_name under provider_data - Update frontend attachments type and consumer to read provider_data.tool_name - Update backend tests for new response contract location - Add explicit RULES.md requirement for non-persisted provider data placement * make environment required * refactor list configs method and fix broken tests
1 parent 2203a99 commit 6d7715b

14 files changed

Lines changed: 1363 additions & 288 deletions

File tree

src/backend/base/langflow/api/v1/mappers/deployments/RULES.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ Use this checklist before merge:
412412
- [ ] Provider-account update logic lives in mapper, not in route conditionals
413413
- [ ] Provider-specific cross-field rules (e.g. tenant/URL coupling) are implemented as mapper overrides calling `super()`, not as base-class conditionals
414414
- [ ] Credential extraction uses `resolve_credential_fields`, not route-level assumptions about `provider_data` contents
415+
- [ ] Non-persisted provider-originated fields are placed in `provider_data` (never top-level)
415416
- [ ] DB-level consistency validators exist as defense-in-depth for cross-field invariants
416417
- [ ] Tests cover both base mapper defaults and provider overrides
417418
- [ ] Failure cases for missing/unexpected bindings are covered
@@ -556,3 +557,14 @@ When adding a new field to an execution or deployment response:
556557
1. Is Langflow the source of truth for this value? → top level.
557558
2. Does this value come from the provider and Langflow just relays it? → inside `provider_data`.
558559
3. Does the provider supply it but Langflow persists and indexes it (like `resource_key`)? → top level is acceptable.
560+
561+
### 14.4 Hard placement rule for non-persisted provider data
562+
563+
If data is not persisted in the Langflow DB and comes directly from the provider,
564+
it must go into `provider_data`.
565+
566+
Rules:
567+
568+
- Top-level response fields are reserved for values that Langflow persists and controls.
569+
- Provider-originated data that Langflow only relays must stay in `provider_data` without exception.
570+
- Examples: provider tool names, execution metadata/status/timestamps, connection types, environments.

src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from lfx.services.adapters.deployment.schema import (
1111
BaseDeploymentData,
1212
BaseDeploymentDataUpdate,
13-
ConfigListItem,
1413
ConfigListResult,
1514
DeploymentCreateResult,
1615
DeploymentListLlmsResult,
@@ -60,6 +59,7 @@
6059
WatsonxApiAddFlowItem,
6160
WatsonxApiAgentExecutionCreateResultData,
6261
WatsonxApiAgentExecutionStatusResultData,
62+
WatsonxApiConfigListItem,
6363
WatsonxApiConfigListProviderData,
6464
WatsonxApiCreatedTool,
6565
WatsonxApiCreateUpsertToolItem,
@@ -173,6 +173,10 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
173173
adapter_model=WatsonxApiConfigListProviderData,
174174
policy=PayloadSlotPolicy.VALIDATE_ONLY,
175175
),
176+
config_item_data=PayloadSlot(
177+
adapter_model=WatsonxApiConfigListItem,
178+
policy=PayloadSlotPolicy.VALIDATE_ONLY,
179+
),
176180
snapshot_list_result=PayloadSlot(
177181
adapter_model=WatsonxApiSnapshotListProviderData,
178182
policy=PayloadSlotPolicy.VALIDATE_ONLY,
@@ -1007,11 +1011,26 @@ def shape_config_list_result(
10071011
msg = "Watsonx config_list_result payload slot is not configured."
10081012
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg)
10091013

1010-
items_all = [self._shape_config_list_item(item) for item in result.configs]
1014+
items_all: list[WatsonxApiConfigListItem] = []
1015+
for item in result.configs:
1016+
if not isinstance(item.provider_data, dict):
1017+
msg = "Invalid config item provider_data payload: expected non-null object."
1018+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg)
1019+
items_all.append(
1020+
self.shape_config_item_data(
1021+
{
1022+
**item.provider_data,
1023+
"connection_id": item.id,
1024+
"app_id": item.name,
1025+
}
1026+
)
1027+
)
10111028
total = len(items_all)
10121029
offset = page_offset(page, size)
10131030
provider_payload = {
1014-
"connections": items_all[offset : offset + size],
1031+
"connections": [
1032+
item.model_dump(mode="json", exclude_none=True) for item in items_all[offset : offset + size]
1033+
],
10151034
"page": page,
10161035
"size": size,
10171036
"total": total,
@@ -1093,8 +1112,10 @@ def shape_flow_version_list_result(
10931112
version_number=row.flow_version.version_number,
10941113
attached_at=row.attachment.created_at,
10951114
provider_snapshot_id=row.snapshot_id,
1096-
tool_name=snapshot_name_by_id.get(row.snapshot_id),
1097-
provider_data=self.shape_deployment_flow_version_item_data(snapshot_data_by_id.get(row.snapshot_id)),
1115+
provider_data=self.shape_deployment_flow_version_item_data(
1116+
snapshot_data=snapshot_data_by_id.get(row.snapshot_id),
1117+
tool_name=snapshot_name_by_id.get(row.snapshot_id),
1118+
),
10981119
)
10991120
for row in normalized_rows
11001121
]
@@ -1159,12 +1180,13 @@ def _resolve_snapshot_name_by_id(
11591180
11601181
Edge cases:
11611182
- Provider unreachable / snapshot_result is None: returns ``{}``.
1162-
The ``tool_name`` field in the response will be ``None`` and the
1163-
frontend falls back to the Langflow flow name for display.
1183+
``provider_data.tool_name`` will be absent/``None`` and the frontend
1184+
falls back to the Langflow flow name for display.
11641185
- Tool renamed in wxO console: the new name is returned here since
11651186
``snapshot_result`` is fetched fresh on each request.
11661187
- Tool deleted in wxO: missing from ``snapshot_result.snapshots``,
1167-
so no entry in the returned dict. ``tool_name`` will be ``None``.
1188+
so no entry in the returned dict. ``provider_data.tool_name`` will be
1189+
absent/``None``.
11681190
"""
11691191
if not snapshot_result or not snapshot_result.snapshots:
11701192
return {}
@@ -1178,17 +1200,21 @@ def _resolve_snapshot_name_by_id(
11781200

11791201
def shape_deployment_flow_version_item_data(
11801202
self,
1203+
*,
11811204
snapshot_data: dict[str, Any] | None,
1205+
tool_name: str | None = None,
11821206
) -> dict[str, Any] | None:
1183-
if not snapshot_data:
1184-
return None
1185-
raw_connections = snapshot_data.get("connections")
1186-
if raw_connections is None or not isinstance(raw_connections, dict):
1207+
raw_connections = snapshot_data.get("connections") if snapshot_data else None
1208+
app_ids = list(raw_connections.keys()) if isinstance(raw_connections, dict) else []
1209+
if not app_ids and not tool_name:
11871210
return None
11881211
try:
11891212
return self._validate_slot(
11901213
self.api_payloads.deployment_item_data,
1191-
{"app_ids": list(raw_connections.keys())},
1214+
{
1215+
"app_ids": app_ids,
1216+
"tool_name": tool_name,
1217+
},
11921218
)
11931219
except AdapterPayloadValidationError as exc:
11941220
detail = exc.format_first_error()
@@ -1225,16 +1251,14 @@ def _shape_provider_deployment_list_entry(self, item: Any) -> dict[str, Any]:
12251251
detail=f"Invalid deployment list item provider_data payload: {detail}",
12261252
) from exc
12271253

1228-
def _shape_config_list_item(self, item: ConfigListItem) -> dict[str, Any]:
1229-
payload: dict[str, Any] = {
1230-
"connection_id": str(item.id).strip(),
1231-
"app_id": str(item.name).strip(),
1232-
}
1233-
item_provider_data = item.provider_data if isinstance(item.provider_data, dict) else {}
1234-
config_type = str(item_provider_data.get("type") or "").strip()
1235-
if config_type:
1236-
payload["type"] = config_type
1237-
return payload
1254+
def shape_config_item_data(self, provider_data: dict[str, Any]) -> WatsonxApiConfigListItem:
1255+
return self._parse_required_payload_slot(
1256+
slot=self.api_payloads.config_item_data,
1257+
slot_name="config_item_data",
1258+
raw=provider_data,
1259+
missing_payload_detail="Config item provider_data payload is missing.",
1260+
malformed_payload_detail="Invalid config item provider_data payload:",
1261+
)
12381262

12391263
def _parse_required_payload_slot(
12401264
self,

src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/payloads.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
BaseModel,
1313
Field,
1414
StringConstraints,
15-
ValidationInfo,
1615
field_validator,
1716
model_validator,
1817
)
@@ -27,6 +26,16 @@
2726
),
2827
]
2928

29+
# Keep API-boundary scalar normalization local to this module instead of
30+
# importing adapter-layer aliases, so mapper contracts can evolve independently.
31+
NormalizedStr = Annotated[
32+
str,
33+
StringConstraints(
34+
strip_whitespace=True,
35+
min_length=1,
36+
),
37+
]
38+
3039

3140
class WatsonxApiFlowArtifactProviderData(CreateFlowArtifactProviderData):
3241
"""Watsonx create-time flow artifact provider_data contract."""
@@ -452,25 +461,10 @@ class WatsonxApiConfigListItem(BaseModel):
452461

453462
model_config = {"extra": "forbid"}
454463

455-
connection_id: str = Field(min_length=1)
456-
app_id: str = Field(min_length=1)
457-
type: str | None = None
458-
459-
@field_validator("connection_id", "app_id", mode="before")
460-
@classmethod
461-
def normalize_required_strings(cls, value: Any, info: ValidationInfo) -> str:
462-
normalized = str(value or "").strip()
463-
if not normalized:
464-
field_name = str(info.field_name)
465-
msg = f"Config list item field '{field_name}' must be a non-empty string."
466-
raise ValueError(msg)
467-
return normalized
468-
469-
@field_validator("type", mode="before")
470-
@classmethod
471-
def normalize_optional_type(cls, value: Any) -> str | None:
472-
normalized = str(value or "").strip()
473-
return normalized or None
464+
connection_id: NormalizedStr
465+
app_id: NormalizedStr
466+
type: NormalizedStr
467+
environment: NormalizedStr
474468

475469

476470
class WatsonxApiConfigListProviderData(BaseModel):
@@ -520,6 +514,7 @@ class WatsonxApiDeploymentFlowVersionItemData(BaseModel):
520514
model_config = {"extra": "forbid"}
521515

522516
app_ids: list[str] = Field(default_factory=list)
517+
tool_name: str | None = None
523518

524519
@field_validator("app_ids", mode="before")
525520
@classmethod
@@ -528,6 +523,22 @@ def normalize_app_ids(cls, value: Any) -> list[str]:
528523
return []
529524
return [str(app_id).strip() for app_id in value if str(app_id).strip()]
530525

526+
@field_validator("tool_name", mode="before")
527+
@classmethod
528+
def normalize_optional_tool_name(cls, value: Any) -> str | None:
529+
normalized = str(value or "").strip()
530+
return normalized or None
531+
532+
533+
class WatsonxApiRenameToolOperation(BaseModel):
534+
"""API-facing rename-tool operation payload."""
535+
536+
model_config = {"extra": "forbid"}
537+
538+
op: Literal["rename_tool"]
539+
flow_version_id: str = Field(min_length=1)
540+
tool_name: NormalizedStr = Field(min_length=1)
541+
531542

532543
class _WatsonxApiAgentExecutionResultBase(BaseModel):
533544
"""Shared fields for API-facing agent execution result payloads.

src/backend/base/langflow/api/v1/schemas/deployments.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -331,15 +331,15 @@ class DeploymentFlowVersionListItem(BaseModel):
331331
distinguishes the following cases:
332332
333333
* **Tool renamed in provider** — Same ``provider_snapshot_id``, different
334-
``tool_name``. Langflow picks up the new name on the next fetch.
334+
``provider_data.tool_name``. Langflow picks up the new name on the next fetch.
335335
* **Tool deleted in provider** — ``provider_snapshot_id`` no longer
336-
resolves. ``tool_name`` and ``provider_data`` will be ``None``.
336+
resolves. ``provider_data.tool_name`` may be missing/``None``.
337337
* **Tool deleted + new tool created with same name** — The new tool has
338338
a different ID. Langflow's attachment still points to the old
339339
(missing) ID. The new tool is invisible to Langflow until explicitly
340340
attached via an update operation.
341341
342-
Frontends should use ``tool_name`` for display and
342+
Frontends should use ``provider_data.tool_name`` for display and
343343
``provider_snapshot_id`` for identity / operations.
344344
"""
345345

@@ -358,15 +358,6 @@ class DeploymentFlowVersionListItem(BaseModel):
358358
default=None,
359359
description="Provider-owned snapshot/tool identifier linked by the attachment.",
360360
)
361-
tool_name: str | None = Field(
362-
default=None,
363-
description=(
364-
"Provider tool name for this snapshot. May differ from ``flow_name`` "
365-
"if the user set a custom name at deploy time or renamed the tool in "
366-
"the provider console. ``None`` when the provider is unreachable or "
367-
"the tool has been deleted — frontends should fall back to ``flow_name``."
368-
),
369-
)
370361
provider_data: dict[str, Any] | None = Field(
371362
default=None,
372363
description="Provider-owned opaque payload for this attached flow version.",

0 commit comments

Comments
 (0)