Skip to content

Commit 43590a9

Browse files
authored
PLT-2761 - Prevent ID change for initial nodes and fix edge ids (#1999)
1 parent dd9d5ee commit 43590a9

File tree

4 files changed

+434
-40
lines changed

4 files changed

+434
-40
lines changed

libs/labelbox/src/labelbox/schema/workflow/edges.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"""
66

77
import logging
8-
import uuid
98
from typing import Dict, Any, Optional, TYPE_CHECKING
109
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
1110

@@ -25,11 +24,22 @@ class WorkflowEdge(BaseModel):
2524
from a source node to a target node through specific handles.
2625
2726
Attributes:
28-
id: Unique identifier for the edge
27+
id: Unique identifier for the edge (format: xy-edge__{source}{sourceHandle}-{target}{targetHandle})
2928
source: ID of the source node
3029
target: ID of the target node
31-
sourceHandle: Output handle on the source node (e.g., 'if', 'else')
30+
sourceHandle: Output handle on the source node (e.g., 'if', 'else', 'approved', 'rejected')
3231
targetHandle: Input handle on the target node (typically 'in')
32+
33+
Edge ID Format:
34+
Edge IDs follow the pattern: xy-edge__{source}{sourceHandle}-{target}{targetHandle}
35+
36+
Example: xy-edge__node1if-node2in
37+
- Prefix: xy-edge__
38+
- Source node ID: node1
39+
- Source handle: if
40+
- Separator: -
41+
- Target node ID: node2
42+
- Target handle: in
3343
"""
3444

3545
id: str
@@ -220,7 +230,13 @@ def _create_edge_instance(
220230
Returns:
221231
Created WorkflowEdge instance
222232
"""
223-
edge_id = f"edge-{uuid.uuid4()}"
233+
# Generate edge ID using the correct format: xy-edge__{source}{sourceHandle}-{target}{targetHandle}
234+
source_handle = output_type.value
235+
target_handle = "in"
236+
edge_id = (
237+
f"xy-edge__{source.id}{source_handle}-{target.id}{target_handle}"
238+
)
239+
224240
logger.debug(
225241
f"Creating edge {edge_id} from {source.id} to {target.id} with type {output_type.value}"
226242
)
@@ -229,8 +245,8 @@ def _create_edge_instance(
229245
id=edge_id,
230246
source=source.id,
231247
target=target.id,
232-
sourceHandle=output_type.value,
233-
targetHandle="in", # Explicitly set targetHandle
248+
sourceHandle=source_handle,
249+
targetHandle=target_handle, # Explicitly set targetHandle
234250
)
235251
edge.set_workflow_reference(self.workflow)
236252
return edge

libs/labelbox/src/labelbox/schema/workflow/workflow.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,10 @@ def reset_to_initial_nodes(
496496
) -> InitialNodes:
497497
"""Reset workflow and create the two required initial nodes.
498498
499-
Clears all existing nodes and edges, then creates:
499+
IMPORTANT: This method preserves existing initial node IDs to prevent workflow breakage.
500+
It only creates new IDs for truly new workflows (first-time setup).
501+
502+
Clears all non-initial nodes and edges, then creates/updates:
500503
- InitialLabeling node: Entry point for new data requiring labeling
501504
- InitialRework node: Entry point for rejected data requiring corrections
502505
@@ -526,11 +529,28 @@ def reset_to_initial_nodes(
526529
rework_config.model_dump(exclude_none=True) if rework_config else {}
527530
)
528531

529-
# Reset workflow configuration
532+
# Find existing initial nodes to preserve their IDs
533+
existing_labeling_id = None
534+
existing_rework_id = None
535+
536+
for node_data in self.config.get("nodes", []):
537+
definition_id = node_data.get("definitionId")
538+
if definition_id == WorkflowDefinitionId.InitialLabelingTask.value:
539+
existing_labeling_id = node_data.get("id")
540+
elif definition_id == WorkflowDefinitionId.InitialReworkTask.value:
541+
existing_rework_id = node_data.get("id")
542+
543+
# Reset workflow configuration (clear all nodes and edges)
530544
self.config = {"nodes": [], "edges": []}
531545
self._nodes_cache = None
532546
self._edges_cache = None
533547

548+
# Create/recreate initial nodes, preserving existing IDs if they exist
549+
if existing_labeling_id:
550+
labeling_dict["id"] = existing_labeling_id
551+
if existing_rework_id:
552+
rework_dict["id"] = existing_rework_id
553+
534554
# Create required initial nodes using internal method
535555
initial_labeling = cast(
536556
InitialLabelingNode,
@@ -554,15 +574,23 @@ def copy_workflow_structure(
554574
target_client,
555575
target_project_id: str,
556576
) -> "ProjectWorkflow":
557-
"""Copy the workflow structure from a source workflow to a new project."""
577+
"""Copy the workflow structure from a source workflow to a new project.
578+
579+
IMPORTANT: This method preserves existing initial node IDs to prevent workflow breakage.
580+
Changing initial node IDs will completely break the workflow and require support intervention.
581+
"""
558582
return WorkflowOperations.copy_workflow_structure(
559583
source_workflow, target_client, target_project_id
560584
)
561585

562586
def copy_from(
563587
self, source_workflow: "ProjectWorkflow", auto_layout: bool = True
564588
) -> "ProjectWorkflow":
565-
"""Copy the nodes and edges from a source workflow to this workflow."""
589+
"""Copy the nodes and edges from a source workflow to this workflow.
590+
591+
IMPORTANT: This method preserves existing initial node IDs to prevent workflow breakage.
592+
Changing initial node IDs will completely break the workflow and require support intervention.
593+
"""
566594
return WorkflowOperations.copy_from(self, source_workflow, auto_layout)
567595

568596
# Layout and display methods

libs/labelbox/src/labelbox/schema/workflow/workflow_operations.py

Lines changed: 105 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,11 @@ def copy_workflow_structure(
390390
target_client,
391391
target_project_id: str,
392392
) -> "ProjectWorkflow":
393-
"""Copy the workflow structure from a source workflow to a new project."""
393+
"""Copy the workflow structure from a source workflow to a new project.
394+
395+
IMPORTANT: This method preserves existing initial node IDs in the target workflow
396+
to prevent workflow breakage. Only non-initial nodes get new IDs.
397+
"""
394398
try:
395399
# Create a new workflow in the target project
396400
from labelbox.schema.workflow.workflow import ProjectWorkflow
@@ -399,38 +403,74 @@ def copy_workflow_structure(
399403
target_client, target_project_id
400404
)
401405

406+
# Find existing initial nodes in target workflow to preserve their IDs
407+
existing_initial_ids = {}
408+
for node_data in target_workflow.config.get("nodes", []):
409+
definition_id = node_data.get("definitionId")
410+
if (
411+
definition_id
412+
== WorkflowDefinitionId.InitialLabelingTask.value
413+
):
414+
existing_initial_ids[
415+
WorkflowDefinitionId.InitialLabelingTask.value
416+
] = node_data.get("id")
417+
elif (
418+
definition_id
419+
== WorkflowDefinitionId.InitialReworkTask.value
420+
):
421+
existing_initial_ids[
422+
WorkflowDefinitionId.InitialReworkTask.value
423+
] = node_data.get("id")
424+
402425
# Get the source config
403426
new_config = source_workflow.config.copy()
404427
old_to_new_id_map = {}
405428

406-
# Generate new IDs for all nodes
429+
# Generate new IDs for all nodes, but preserve existing initial node IDs
407430
if new_config.get("nodes"):
408-
new_config["nodes"] = [
409-
{
410-
**node,
411-
"id": str(uuid.uuid4()),
412-
}
413-
for node in new_config["nodes"]
414-
]
415-
# Create mapping of old to new IDs
416-
old_to_new_id_map = {
417-
old_node["id"]: new_node["id"]
418-
for old_node, new_node in zip(
419-
source_workflow.config["nodes"], new_config["nodes"]
431+
updated_nodes = []
432+
for node in new_config["nodes"]:
433+
definition_id = node.get("definitionId")
434+
old_id = node["id"]
435+
436+
# Preserve existing initial node IDs, generate new IDs for others
437+
if definition_id in existing_initial_ids:
438+
new_id = existing_initial_ids[definition_id]
439+
else:
440+
new_id = str(uuid.uuid4())
441+
442+
old_to_new_id_map[old_id] = new_id
443+
updated_nodes.append(
444+
{
445+
**node,
446+
"id": new_id,
447+
}
420448
)
421-
}
449+
450+
new_config["nodes"] = updated_nodes
422451

423452
# Update edges to use the new node IDs
424453
if new_config.get("edges"):
425-
new_config["edges"] = [
426-
{
427-
**edge,
428-
"id": str(uuid.uuid4()),
429-
"source": old_to_new_id_map[edge["source"]],
430-
"target": old_to_new_id_map[edge["target"]],
431-
}
432-
for edge in new_config["edges"]
433-
]
454+
updated_edges = []
455+
for edge in new_config["edges"]:
456+
source_id = old_to_new_id_map[edge["source"]]
457+
target_id = old_to_new_id_map[edge["target"]]
458+
source_handle = edge.get("sourceHandle", "if")
459+
target_handle = edge.get("targetHandle", "in")
460+
461+
# Generate edge ID using correct format: xy-edge__{source}{sourceHandle}-{target}{targetHandle}
462+
edge_id = f"xy-edge__{source_id}{source_handle}-{target_id}{target_handle}"
463+
464+
updated_edges.append(
465+
{
466+
**edge,
467+
"id": edge_id,
468+
"source": source_id,
469+
"target": target_id,
470+
}
471+
)
472+
473+
new_config["edges"] = updated_edges
434474

435475
# Update the target workflow with the new config
436476
target_workflow.config = new_config
@@ -450,8 +490,31 @@ def copy_from(
450490
source_workflow: "ProjectWorkflow",
451491
auto_layout: bool = True,
452492
) -> "ProjectWorkflow":
453-
"""Copy the nodes and edges from a source workflow to this workflow."""
493+
"""Copy the nodes and edges from a source workflow to this workflow.
494+
495+
IMPORTANT: This method preserves existing initial node IDs in the target workflow
496+
to prevent workflow breakage. Only non-initial nodes get new IDs.
497+
"""
454498
try:
499+
# Find existing initial nodes in target workflow to preserve their IDs
500+
existing_initial_ids = {}
501+
for node_data in workflow.config.get("nodes", []):
502+
definition_id = node_data.get("definitionId")
503+
if (
504+
definition_id
505+
== WorkflowDefinitionId.InitialLabelingTask.value
506+
):
507+
existing_initial_ids[
508+
WorkflowDefinitionId.InitialLabelingTask.value
509+
] = node_data.get("id")
510+
elif (
511+
definition_id
512+
== WorkflowDefinitionId.InitialReworkTask.value
513+
):
514+
existing_initial_ids[
515+
WorkflowDefinitionId.InitialReworkTask.value
516+
] = node_data.get("id")
517+
455518
# Create a clean work config (without connections)
456519
work_config: Dict[str, List[Any]] = {"nodes": [], "edges": []}
457520

@@ -463,9 +526,15 @@ def copy_from(
463526

464527
# First pass: Create all nodes by directly copying configuration
465528
for source_node_data in source_workflow.config.get("nodes", []):
466-
# Generate a new ID for the node
467-
new_id = f"node-{uuid.uuid4()}"
529+
definition_id = source_node_data.get("definitionId")
468530
old_id = source_node_data.get("id")
531+
532+
# Preserve existing initial node IDs, generate new IDs for others
533+
if definition_id in existing_initial_ids:
534+
new_id = existing_initial_ids[definition_id]
535+
else:
536+
new_id = f"node-{uuid.uuid4()}"
537+
469538
id_mapping[old_id] = new_id
470539

471540
# Create a new node data dictionary by copying the source node
@@ -498,12 +567,18 @@ def copy_from(
498567
continue
499568

500569
# Create new edge
570+
source_handle = source_edge_data.get("sourceHandle", "out")
571+
target_handle = source_edge_data.get("targetHandle", "in")
572+
573+
# Generate edge ID using correct format: xy-edge__{source}{sourceHandle}-{target}{targetHandle}
574+
edge_id = f"xy-edge__{id_mapping[source_id]}{source_handle}-{id_mapping[target_id]}{target_handle}"
575+
501576
new_edge = {
502-
"id": f"edge-{uuid.uuid4()}",
577+
"id": edge_id,
503578
"source": id_mapping[source_id],
504579
"target": id_mapping[target_id],
505-
"sourceHandle": source_edge_data.get("sourceHandle", "out"),
506-
"targetHandle": source_edge_data.get("targetHandle", "in"),
580+
"sourceHandle": source_handle,
581+
"targetHandle": target_handle,
507582
}
508583

509584
# Add the edge to config

0 commit comments

Comments
 (0)