griptape-nodes 0.44.0__py3-none-any.whl → 0.45.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. griptape_nodes/__init__.py +5 -1
  2. griptape_nodes/app/api.py +2 -35
  3. griptape_nodes/app/app.py +70 -3
  4. griptape_nodes/app/watch.py +5 -2
  5. griptape_nodes/drivers/storage/base_storage_driver.py +37 -0
  6. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +2 -1
  7. griptape_nodes/exe_types/core_types.py +109 -9
  8. griptape_nodes/exe_types/node_types.py +19 -5
  9. griptape_nodes/node_library/workflow_registry.py +29 -0
  10. griptape_nodes/retained_mode/events/app_events.py +3 -2
  11. griptape_nodes/retained_mode/events/base_events.py +9 -0
  12. griptape_nodes/retained_mode/events/sync_events.py +60 -0
  13. griptape_nodes/retained_mode/events/workflow_events.py +231 -0
  14. griptape_nodes/retained_mode/griptape_nodes.py +8 -0
  15. griptape_nodes/retained_mode/managers/library_manager.py +6 -18
  16. griptape_nodes/retained_mode/managers/node_manager.py +2 -2
  17. griptape_nodes/retained_mode/managers/operation_manager.py +7 -0
  18. griptape_nodes/retained_mode/managers/settings.py +5 -0
  19. griptape_nodes/retained_mode/managers/sync_manager.py +498 -0
  20. griptape_nodes/retained_mode/managers/workflow_manager.py +682 -28
  21. griptape_nodes/retained_mode/retained_mode.py +23 -0
  22. griptape_nodes/updater/__init__.py +4 -2
  23. griptape_nodes/utils/uv_utils.py +18 -0
  24. {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/METADATA +2 -1
  25. {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/RECORD +27 -24
  26. {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/WHEEL +1 -1
  27. {griptape_nodes-0.44.0.dist-info → griptape_nodes-0.45.1.dist-info}/entry_points.txt +0 -0
@@ -45,6 +45,12 @@ from griptape_nodes.retained_mode.events.library_events import (
45
45
  )
46
46
  from griptape_nodes.retained_mode.events.object_events import ClearAllObjectStateRequest
47
47
  from griptape_nodes.retained_mode.events.workflow_events import (
48
+ BranchWorkflowRequest,
49
+ BranchWorkflowResultFailure,
50
+ BranchWorkflowResultSuccess,
51
+ CompareWorkflowsRequest,
52
+ CompareWorkflowsResultFailure,
53
+ CompareWorkflowsResultSuccess,
48
54
  DeleteWorkflowRequest,
49
55
  DeleteWorkflowResultFailure,
50
56
  DeleteWorkflowResultSuccess,
@@ -57,14 +63,27 @@ from griptape_nodes.retained_mode.events.workflow_events import (
57
63
  LoadWorkflowMetadata,
58
64
  LoadWorkflowMetadataResultFailure,
59
65
  LoadWorkflowMetadataResultSuccess,
66
+ MergeWorkflowBranchRequest,
67
+ MergeWorkflowBranchResultFailure,
68
+ MergeWorkflowBranchResultSuccess,
69
+ MoveWorkflowRequest,
70
+ MoveWorkflowResultFailure,
71
+ MoveWorkflowResultSuccess,
60
72
  PublishWorkflowRequest,
61
73
  PublishWorkflowResultFailure,
74
+ PublishWorkflowResultSuccess,
62
75
  RegisterWorkflowRequest,
63
76
  RegisterWorkflowResultFailure,
64
77
  RegisterWorkflowResultSuccess,
78
+ RegisterWorkflowsFromConfigRequest,
79
+ RegisterWorkflowsFromConfigResultFailure,
80
+ RegisterWorkflowsFromConfigResultSuccess,
65
81
  RenameWorkflowRequest,
66
82
  RenameWorkflowResultFailure,
67
83
  RenameWorkflowResultSuccess,
84
+ ResetWorkflowBranchRequest,
85
+ ResetWorkflowBranchResultFailure,
86
+ ResetWorkflowBranchResultSuccess,
68
87
  RunWorkflowFromRegistryRequest,
69
88
  RunWorkflowFromRegistryResultFailure,
70
89
  RunWorkflowFromRegistryResultSuccess,
@@ -101,6 +120,13 @@ T = TypeVar("T")
101
120
  logger = logging.getLogger("griptape_nodes")
102
121
 
103
122
 
123
+ class WorkflowRegistrationResult(NamedTuple):
124
+ """Result of processing workflows for registration."""
125
+
126
+ succeeded: list[str]
127
+ failed: list[str]
128
+
129
+
104
130
  class WorkflowManager:
105
131
  WORKFLOW_METADATA_HEADER: ClassVar[str] = "script"
106
132
  MAX_MINOR_VERSION_DEVIATION: ClassVar[int] = (
@@ -227,6 +253,10 @@ class WorkflowManager:
227
253
  RenameWorkflowRequest,
228
254
  self.on_rename_workflow_request,
229
255
  )
256
+ event_manager.assign_manager_to_request_type(
257
+ MoveWorkflowRequest,
258
+ self.on_move_workflow_request,
259
+ )
230
260
 
231
261
  event_manager.assign_manager_to_request_type(
232
262
  SaveWorkflowRequest,
@@ -241,6 +271,26 @@ class WorkflowManager:
241
271
  ImportWorkflowAsReferencedSubFlowRequest,
242
272
  self.on_import_workflow_as_referenced_sub_flow_request,
243
273
  )
274
+ event_manager.assign_manager_to_request_type(
275
+ BranchWorkflowRequest,
276
+ self.on_branch_workflow_request,
277
+ )
278
+ event_manager.assign_manager_to_request_type(
279
+ MergeWorkflowBranchRequest,
280
+ self.on_merge_workflow_branch_request,
281
+ )
282
+ event_manager.assign_manager_to_request_type(
283
+ ResetWorkflowBranchRequest,
284
+ self.on_reset_workflow_branch_request,
285
+ )
286
+ event_manager.assign_manager_to_request_type(
287
+ CompareWorkflowsRequest,
288
+ self.on_compare_workflows_request,
289
+ )
290
+ event_manager.assign_manager_to_request_type(
291
+ RegisterWorkflowsFromConfigRequest,
292
+ self.on_register_workflows_from_config_request,
293
+ )
244
294
 
245
295
  def has_current_referenced_workflow(self) -> bool:
246
296
  """Check if there is currently a referenced workflow context active."""
@@ -258,7 +308,15 @@ class WorkflowManager:
258
308
  # All of the libraries have loaded, and any workflows they came with have been registered.
259
309
  # See if there are USER workflow JSONs to load.
260
310
  default_workflow_section = "app_events.on_app_initialization_complete.workflows_to_register"
261
- self.register_workflows_from_config(config_section=default_workflow_section)
311
+
312
+ # Use the request/response pattern for workflow registration
313
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
314
+
315
+ register_request = RegisterWorkflowsFromConfigRequest(config_section=default_workflow_section)
316
+ register_result = GriptapeNodes.handle_request(register_request)
317
+
318
+ if not isinstance(register_result, RegisterWorkflowsFromConfigResultSuccess):
319
+ logger.warning("Failed to register workflows from configuration during library initialization")
262
320
 
263
321
  # Print it all out nicely.
264
322
  self.print_workflow_load_status()
@@ -611,6 +669,82 @@ class WorkflowManager:
611
669
 
612
670
  return RenameWorkflowResultSuccess()
613
671
 
672
+ def on_move_workflow_request(self, request: MoveWorkflowRequest) -> ResultPayload: # noqa: PLR0911
673
+ try:
674
+ # Validate source workflow exists
675
+ workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
676
+ except KeyError:
677
+ logger.error("Failed to move workflow '%s' because it does not exist", request.workflow_name)
678
+ return MoveWorkflowResultFailure()
679
+
680
+ config_manager = GriptapeNodes.ConfigManager()
681
+
682
+ # Get current file path
683
+ current_file_path = WorkflowRegistry.get_complete_file_path(workflow.file_path)
684
+ if not Path(current_file_path).exists():
685
+ logger.error(
686
+ "Failed to move workflow '%s': File path '%s' does not exist", request.workflow_name, current_file_path
687
+ )
688
+ return MoveWorkflowResultFailure()
689
+
690
+ # Clean and validate target directory
691
+ target_directory = request.target_directory.strip().replace("\\", "/")
692
+ target_directory = target_directory.removeprefix("/") # Remove leading slash
693
+
694
+ # Create target directory path
695
+ target_dir_path = config_manager.workspace_path / target_directory
696
+
697
+ try:
698
+ # Create target directory if it doesn't exist
699
+ target_dir_path.mkdir(parents=True, exist_ok=True)
700
+ except OSError as e:
701
+ logger.error("Failed to create target directory '%s': %s", target_dir_path, str(e))
702
+ return MoveWorkflowResultFailure()
703
+
704
+ # Create new file path
705
+ workflow_filename = Path(workflow.file_path).name
706
+ new_relative_path = str(Path(target_directory) / workflow_filename)
707
+ new_absolute_path = config_manager.workspace_path / new_relative_path
708
+
709
+ # Check if target file already exists
710
+ if new_absolute_path.exists():
711
+ logger.error(
712
+ "Failed to move workflow '%s': Target file '%s' already exists",
713
+ request.workflow_name,
714
+ new_absolute_path,
715
+ )
716
+ return MoveWorkflowResultFailure()
717
+
718
+ try:
719
+ # Move the file
720
+ Path(current_file_path).rename(new_absolute_path)
721
+
722
+ # Update workflow registry with new file path
723
+ workflow.file_path = new_relative_path
724
+
725
+ # Update configuration - remove old path and add new path
726
+ config_manager.delete_user_workflow(workflow.file_path)
727
+ config_manager.save_user_workflow_json(str(new_absolute_path))
728
+
729
+ except OSError as e:
730
+ logger.error("Failed to move workflow file '%s' to '%s': %s", current_file_path, new_absolute_path, str(e))
731
+
732
+ # Attempt to rollback if file was moved but registry update failed
733
+ if new_absolute_path.exists() and not Path(current_file_path).exists():
734
+ try:
735
+ new_absolute_path.rename(current_file_path)
736
+ logger.info("Rolled back file move for workflow '%s'", request.workflow_name)
737
+ except OSError:
738
+ logger.error("Failed to rollback file move for workflow '%s'", request.workflow_name)
739
+
740
+ return MoveWorkflowResultFailure()
741
+ except Exception as e:
742
+ logger.error("Failed to move workflow '%s': %s", request.workflow_name, str(e))
743
+ return MoveWorkflowResultFailure()
744
+ else:
745
+ logger.info("Successfully moved workflow '%s' to '%s'", request.workflow_name, new_relative_path)
746
+ return MoveWorkflowResultSuccess(moved_file_path=new_relative_path)
747
+
614
748
  def on_load_workflow_metadata_request( # noqa: C901, PLR0912, PLR0915
615
749
  self, request: LoadWorkflowMetadata
616
750
  ) -> ResultPayload:
@@ -933,7 +1067,12 @@ class WorkflowManager:
933
1067
  return import_statements
934
1068
 
935
1069
  def _generate_workflow_file_contents_and_metadata( # noqa: C901, PLR0912, PLR0915
936
- self, file_name: str, creation_date: datetime, image_path: str | None = None
1070
+ self,
1071
+ file_name: str,
1072
+ creation_date: datetime,
1073
+ image_path: str | None = None,
1074
+ prior_workflow: Workflow | None = None,
1075
+ custom_metadata: WorkflowMetadata | None = None,
937
1076
  ) -> tuple[str, WorkflowMetadata]:
938
1077
  """Generate the contents of a workflow file.
939
1078
 
@@ -941,6 +1080,10 @@ class WorkflowManager:
941
1080
  file_name: The name of the workflow file
942
1081
  creation_date: The creation date for the workflow
943
1082
  image_path: Optional; the path to an image to include in the workflow metadata
1083
+ prior_workflow: Optional; existing workflow to preserve branch info from
1084
+ custom_metadata: Optional; pre-constructed metadata to use instead of generating it
1085
+ from the current workflow state. When provided, this metadata will be
1086
+ used directly, allowing branch/merge operations to pass specific metadata.
944
1087
 
945
1088
  Returns:
946
1089
  A tuple of (workflow_file_contents, workflow_metadata)
@@ -989,22 +1132,27 @@ class WorkflowManager:
989
1132
  raise TypeError(details)
990
1133
  serialized_flow_commands = serialized_flow_result.serialized_flow_commands
991
1134
 
992
- # Create the Workflow Metadata header.
993
- workflows_referenced = None
994
- if serialized_flow_commands.referenced_workflows:
995
- workflows_referenced = list(serialized_flow_commands.referenced_workflows)
996
-
997
- workflow_metadata = self._generate_workflow_metadata(
998
- file_name=file_name,
999
- engine_version=engine_version,
1000
- creation_date=creation_date,
1001
- node_libraries_referenced=list(serialized_flow_commands.node_libraries_used),
1002
- workflows_referenced=workflows_referenced,
1003
- )
1004
- if workflow_metadata is None:
1005
- details = f"Failed to generate metadata for workflow '{file_name}'."
1006
- logger.error(details)
1007
- raise ValueError(details)
1135
+ # Use custom metadata if provided, otherwise generate it
1136
+ if custom_metadata is not None:
1137
+ workflow_metadata = custom_metadata
1138
+ else:
1139
+ # Create the Workflow Metadata header.
1140
+ workflows_referenced = None
1141
+ if serialized_flow_commands.referenced_workflows:
1142
+ workflows_referenced = list(serialized_flow_commands.referenced_workflows)
1143
+
1144
+ workflow_metadata = self._generate_workflow_metadata(
1145
+ file_name=file_name,
1146
+ engine_version=engine_version,
1147
+ creation_date=creation_date,
1148
+ node_libraries_referenced=list(serialized_flow_commands.node_libraries_used),
1149
+ workflows_referenced=workflows_referenced,
1150
+ prior_workflow=prior_workflow,
1151
+ )
1152
+ if workflow_metadata is None:
1153
+ details = f"Failed to generate metadata for workflow '{file_name}'."
1154
+ logger.error(details)
1155
+ raise ValueError(details)
1008
1156
 
1009
1157
  # Set the image if provided
1010
1158
  if image_path:
@@ -1143,8 +1291,6 @@ class WorkflowManager:
1143
1291
  return final_code_output, workflow_metadata
1144
1292
 
1145
1293
  def on_save_workflow_request(self, request: SaveWorkflowRequest) -> ResultPayload: # noqa: C901, PLR0912, PLR0915
1146
- local_tz = datetime.now().astimezone().tzinfo
1147
-
1148
1294
  # Start with the file name provided; we may change it.
1149
1295
  file_name = request.file_name
1150
1296
 
@@ -1156,10 +1302,19 @@ class WorkflowManager:
1156
1302
  prior_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1157
1303
  # We'll use its creation date.
1158
1304
  creation_date = prior_workflow.metadata.creation_date
1305
+ elif file_name:
1306
+ # If no prior workflow exists for the new name, check if there's a current workflow
1307
+ # context (e.g., during rename operations) to preserve metadata from
1308
+ context_manager = GriptapeNodes.ContextManager()
1309
+ if context_manager.has_current_workflow():
1310
+ current_workflow_name = context_manager.get_current_workflow_name()
1311
+ if current_workflow_name and WorkflowRegistry.has_workflow_with_name(current_workflow_name):
1312
+ prior_workflow = WorkflowRegistry.get_workflow_by_name(current_workflow_name)
1313
+ creation_date = prior_workflow.metadata.creation_date
1159
1314
 
1160
1315
  if (creation_date is None) or (creation_date == WorkflowManager.EPOCH_START):
1161
1316
  # Either a new workflow, or a backcompat situation.
1162
- creation_date = datetime.now(tz=local_tz)
1317
+ creation_date = datetime.now(tz=UTC)
1163
1318
 
1164
1319
  # Let's see if this is a template file; if so, re-route it as a copy in the customer's workflow directory.
1165
1320
  if prior_workflow and prior_workflow.metadata.is_template:
@@ -1181,14 +1336,17 @@ class WorkflowManager:
1181
1336
 
1182
1337
  # Get file name stuff prepped.
1183
1338
  if not file_name:
1184
- file_name = datetime.now(tz=local_tz).strftime("%d.%m_%H.%M")
1339
+ file_name = datetime.now(tz=UTC).strftime("%d.%m_%H.%M")
1185
1340
  relative_file_path = f"{file_name}.py"
1186
1341
  file_path = GriptapeNodes.ConfigManager().workspace_path.joinpath(relative_file_path)
1187
1342
 
1188
1343
  # Generate the workflow file contents
1189
1344
  try:
1190
1345
  final_code_output, workflow_metadata = self._generate_workflow_file_contents_and_metadata(
1191
- file_name=file_name, creation_date=creation_date, image_path=request.image_path
1346
+ file_name=file_name,
1347
+ creation_date=creation_date,
1348
+ image_path=request.image_path,
1349
+ prior_workflow=prior_workflow,
1192
1350
  )
1193
1351
  except Exception as err:
1194
1352
  details = f"Attempted to save workflow '{relative_file_path}', but {err}"
@@ -1231,19 +1389,27 @@ class WorkflowManager:
1231
1389
  logger.error("Attempted to save workflow '%s'. Failed when saving configuration: %s", file_name, str(e))
1232
1390
  return SaveWorkflowResultFailure()
1233
1391
  WorkflowRegistry.generate_new_workflow(metadata=workflow_metadata, file_path=relative_file_path)
1392
+ # Update existing workflow's metadata in the registry
1393
+ existing_workflow = WorkflowRegistry.get_workflow_by_name(file_name)
1394
+ existing_workflow.metadata = workflow_metadata
1234
1395
  details = f"Successfully saved workflow to: {serialized_file_path}"
1235
1396
  logger.info(details)
1236
1397
  return SaveWorkflowResultSuccess(file_path=str(serialized_file_path))
1237
1398
 
1238
- def _generate_workflow_metadata(
1399
+ def _generate_workflow_metadata( # noqa: PLR0913
1239
1400
  self,
1240
1401
  file_name: str,
1241
1402
  engine_version: str,
1242
1403
  creation_date: datetime,
1243
1404
  node_libraries_referenced: list[LibraryNameAndVersion],
1244
1405
  workflows_referenced: list[str] | None = None,
1406
+ prior_workflow: Workflow | None = None,
1245
1407
  ) -> WorkflowMetadata | None:
1246
- local_tz = datetime.now().astimezone().tzinfo
1408
+ # Preserve branched workflow information if it exists
1409
+ branched_from = None
1410
+ if prior_workflow and prior_workflow.metadata.branched_from:
1411
+ branched_from = prior_workflow.metadata.branched_from
1412
+
1247
1413
  workflow_metadata = WorkflowMetadata(
1248
1414
  name=str(file_name),
1249
1415
  schema_version=WorkflowMetadata.LATEST_SCHEMA_VERSION,
@@ -1251,11 +1417,35 @@ class WorkflowManager:
1251
1417
  node_libraries_referenced=node_libraries_referenced,
1252
1418
  workflows_referenced=workflows_referenced,
1253
1419
  creation_date=creation_date,
1254
- last_modified_date=datetime.now(tz=local_tz),
1420
+ last_modified_date=datetime.now(tz=UTC),
1421
+ branched_from=branched_from,
1255
1422
  )
1256
1423
 
1257
1424
  return workflow_metadata
1258
1425
 
1426
+ def _replace_workflow_metadata_header(self, workflow_content: str, new_metadata: WorkflowMetadata) -> str | None:
1427
+ """Replace the metadata header in a workflow file with new metadata.
1428
+
1429
+ Args:
1430
+ workflow_content: The full content of the workflow file
1431
+ new_metadata: The new metadata to replace the existing header with
1432
+
1433
+ Returns:
1434
+ The workflow content with updated metadata header, or None if replacement failed
1435
+ """
1436
+ import re
1437
+
1438
+ # Generate the new metadata header
1439
+ new_metadata_header = self._generate_workflow_metadata_header(new_metadata)
1440
+ if new_metadata_header is None:
1441
+ return None
1442
+
1443
+ # Replace the metadata block using regex
1444
+ metadata_pattern = r"(# /// script\n)(.*?)(# ///)"
1445
+ updated_content = re.sub(metadata_pattern, new_metadata_header, workflow_content, flags=re.DOTALL)
1446
+
1447
+ return updated_content
1448
+
1259
1449
  def _generate_workflow_metadata_header(self, workflow_metadata: WorkflowMetadata) -> str | None:
1260
1450
  try:
1261
1451
  toml_doc = tomlkit.document()
@@ -2776,13 +2966,47 @@ class WorkflowManager:
2776
2966
  msg = f"No publishing handler found for '{publisher_name}' in request type '{type(request).__name__}'."
2777
2967
  raise ValueError(msg) # noqa: TRY301
2778
2968
 
2779
- return publishing_handler.handler(request)
2780
-
2969
+ result = publishing_handler.handler(request)
2970
+ if isinstance(result, PublishWorkflowResultSuccess):
2971
+ file = Path(result.published_workflow_file_path)
2972
+ self._register_published_workflow_file(file)
2973
+ return result # noqa: TRY300
2781
2974
  except Exception as e:
2782
2975
  details = f"Failed to publish workflow '{request.workflow_name}': {e!s}"
2783
2976
  logger.exception(details)
2784
2977
  return PublishWorkflowResultFailure(exception=e)
2785
2978
 
2979
+ def _register_published_workflow_file(self, workflow_file: Path) -> None:
2980
+ """Register a published workflow file in the workflow registry."""
2981
+ if workflow_file.exists() and workflow_file.is_file():
2982
+ load_workflow_metadata_request = LoadWorkflowMetadata(
2983
+ file_name=workflow_file.name,
2984
+ )
2985
+ load_metadata_result = self.on_load_workflow_metadata_request(load_workflow_metadata_request)
2986
+ if isinstance(load_metadata_result, LoadWorkflowMetadataResultSuccess):
2987
+ register_workflow_result = self.on_register_workflow_request(
2988
+ RegisterWorkflowRequest(
2989
+ metadata=load_metadata_result.metadata,
2990
+ file_name=workflow_file.name,
2991
+ )
2992
+ )
2993
+ if isinstance(register_workflow_result, RegisterWorkflowResultSuccess):
2994
+ logger.info(
2995
+ "Successfully registered new workflow with file '%s'.",
2996
+ workflow_file.name,
2997
+ )
2998
+ else:
2999
+ logger.warning(
3000
+ "Failed to register workflow with file '%s': %s",
3001
+ workflow_file.name,
3002
+ cast("RegisterWorkflowResultFailure", register_workflow_result).exception,
3003
+ )
3004
+ else:
3005
+ logger.warning(
3006
+ "Failed to load metadata for workflow file '%s'. Not registering workflow.",
3007
+ workflow_file.name,
3008
+ )
3009
+
2786
3010
  def on_import_workflow_as_referenced_sub_flow_request(
2787
3011
  self, request: ImportWorkflowAsReferencedSubFlowRequest
2788
3012
  ) -> ResultPayload:
@@ -2924,6 +3148,341 @@ class WorkflowManager:
2924
3148
  )
2925
3149
  return ImportWorkflowAsReferencedSubFlowResultSuccess(created_flow_name=created_flow_name)
2926
3150
 
3151
+ def on_branch_workflow_request(self, request: BranchWorkflowRequest) -> ResultPayload:
3152
+ """Create a branch (copy) of an existing workflow with branch tracking."""
3153
+ try:
3154
+ # Validate source workflow exists
3155
+ source_workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3156
+ except KeyError:
3157
+ logger.error("Failed to branch workflow '%s' because it does not exist", request.workflow_name)
3158
+ return BranchWorkflowResultFailure()
3159
+
3160
+ # Generate branch name if not provided
3161
+ branch_name = request.branched_workflow_name
3162
+ if branch_name is None:
3163
+ base_name = request.workflow_name
3164
+ counter = 1
3165
+ branch_name = f"{base_name}_branch_{counter}"
3166
+ while WorkflowRegistry.has_workflow_with_name(branch_name):
3167
+ counter += 1
3168
+ branch_name = f"{base_name}_branch_{counter}"
3169
+
3170
+ # Check if branch name already exists
3171
+ if WorkflowRegistry.has_workflow_with_name(branch_name):
3172
+ logger.error(
3173
+ "Failed to branch workflow '%s' because branch name '%s' already exists",
3174
+ request.workflow_name,
3175
+ branch_name,
3176
+ )
3177
+ return BranchWorkflowResultFailure()
3178
+
3179
+ try:
3180
+ # Create branch metadata by copying source metadata
3181
+ branch_metadata = WorkflowMetadata(
3182
+ name=branch_name,
3183
+ schema_version=source_workflow.metadata.schema_version,
3184
+ engine_version_created_with=source_workflow.metadata.engine_version_created_with,
3185
+ node_libraries_referenced=source_workflow.metadata.node_libraries_referenced.copy(),
3186
+ workflows_referenced=source_workflow.metadata.workflows_referenced.copy()
3187
+ if source_workflow.metadata.workflows_referenced
3188
+ else None,
3189
+ description=source_workflow.metadata.description,
3190
+ image=source_workflow.metadata.image,
3191
+ is_griptape_provided=False, # Branches are always user-created
3192
+ is_template=False,
3193
+ creation_date=datetime.now(tz=UTC),
3194
+ last_modified_date=source_workflow.metadata.last_modified_date,
3195
+ branched_from=request.workflow_name,
3196
+ )
3197
+
3198
+ # Prepare branch file path
3199
+ branch_file_path = f"{branch_name}.py"
3200
+
3201
+ # Read source workflow content and replace metadata header
3202
+ source_file_path = WorkflowRegistry.get_complete_file_path(source_workflow.file_path)
3203
+ if not Path(source_file_path).exists():
3204
+ logger.error(
3205
+ "Failed to branch workflow '%s': File path '%s' does not exist. "
3206
+ "The workflow may have been moved or the workspace configuration may have changed.",
3207
+ request.workflow_name,
3208
+ source_file_path,
3209
+ )
3210
+ return BranchWorkflowResultFailure()
3211
+
3212
+ source_content = Path(source_file_path).read_text(encoding="utf-8")
3213
+
3214
+ # Replace the metadata header with branch metadata
3215
+ branch_content = self._replace_workflow_metadata_header(source_content, branch_metadata)
3216
+ if branch_content is None:
3217
+ logger.error("Failed to replace metadata header for branch workflow '%s'", branch_name)
3218
+ return BranchWorkflowResultFailure()
3219
+
3220
+ # Write branch workflow file to disk BEFORE registering in registry
3221
+ branch_full_path = WorkflowRegistry.get_complete_file_path(branch_file_path)
3222
+ Path(branch_full_path).write_text(branch_content, encoding="utf-8")
3223
+
3224
+ # Now create the branch workflow in registry (file must exist on disk first)
3225
+ WorkflowRegistry.generate_new_workflow(metadata=branch_metadata, file_path=branch_file_path)
3226
+
3227
+ # Register the branch in user configuration
3228
+ config_manager = GriptapeNodes.ConfigManager()
3229
+ config_manager.save_user_workflow_json(branch_full_path)
3230
+
3231
+ logger.info("Successfully branched workflow '%s' as '%s'", request.workflow_name, branch_name)
3232
+ return BranchWorkflowResultSuccess(
3233
+ branched_workflow_name=branch_name, original_workflow_name=request.workflow_name
3234
+ )
3235
+
3236
+ except Exception as e:
3237
+ logger.error("Failed to branch workflow '%s': %s", request.workflow_name, str(e))
3238
+ import traceback
3239
+
3240
+ traceback.print_exc()
3241
+ return BranchWorkflowResultFailure()
3242
+
3243
+ def on_merge_workflow_branch_request(self, request: MergeWorkflowBranchRequest) -> ResultPayload:
3244
+ """Merge a branch back into its source workflow, removing the branch when complete."""
3245
+ try:
3246
+ # Validate branch workflow exists
3247
+ branch_workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3248
+ except KeyError as e:
3249
+ logger.error("Failed to merge workflow branch because it does not exist: %s", str(e))
3250
+ return MergeWorkflowBranchResultFailure()
3251
+
3252
+ # Get source workflow name from branch metadata
3253
+ source_workflow_name = branch_workflow.metadata.branched_from
3254
+ if not source_workflow_name:
3255
+ logger.error(
3256
+ "Failed to merge workflow branch '%s' because it has no source workflow",
3257
+ request.workflow_name,
3258
+ )
3259
+ return MergeWorkflowBranchResultFailure()
3260
+
3261
+ # Validate source workflow exists
3262
+ try:
3263
+ source_workflow = WorkflowRegistry.get_workflow_by_name(source_workflow_name)
3264
+ except KeyError:
3265
+ logger.error(
3266
+ "Failed to merge workflow branch '%s' because source workflow '%s' does not exist",
3267
+ request.workflow_name,
3268
+ source_workflow_name,
3269
+ )
3270
+ return MergeWorkflowBranchResultFailure()
3271
+
3272
+ try:
3273
+ # Create updated metadata for source workflow - update timestamp
3274
+ merged_metadata = WorkflowMetadata(
3275
+ name=source_workflow_name,
3276
+ schema_version=source_workflow.metadata.schema_version,
3277
+ engine_version_created_with=source_workflow.metadata.engine_version_created_with,
3278
+ node_libraries_referenced=source_workflow.metadata.node_libraries_referenced.copy(),
3279
+ workflows_referenced=source_workflow.metadata.workflows_referenced.copy()
3280
+ if source_workflow.metadata.workflows_referenced
3281
+ else None,
3282
+ description=source_workflow.metadata.description,
3283
+ image=source_workflow.metadata.image,
3284
+ is_griptape_provided=source_workflow.metadata.is_griptape_provided,
3285
+ is_template=source_workflow.metadata.is_template,
3286
+ creation_date=source_workflow.metadata.creation_date,
3287
+ last_modified_date=datetime.now(tz=UTC),
3288
+ branched_from=source_workflow.metadata.branched_from, # Preserve original source chain
3289
+ )
3290
+
3291
+ # Read branch content and replace metadata header with merged metadata
3292
+ branch_content_file_path = WorkflowRegistry.get_complete_file_path(branch_workflow.file_path)
3293
+ branch_content = Path(branch_content_file_path).read_text(encoding="utf-8")
3294
+
3295
+ # Replace the metadata header with merged metadata
3296
+ merged_content = self._replace_workflow_metadata_header(branch_content, merged_metadata)
3297
+ if merged_content is None:
3298
+ logger.error("Failed to replace metadata header for merged workflow '%s'", source_workflow_name)
3299
+ return MergeWorkflowBranchResultFailure()
3300
+
3301
+ # Write the updated content to the source workflow file
3302
+ source_file_path = WorkflowRegistry.get_complete_file_path(source_workflow.file_path)
3303
+ Path(source_file_path).write_text(merged_content, encoding="utf-8")
3304
+
3305
+ # Update the registry with new metadata for the source workflow
3306
+ source_workflow.metadata = merged_metadata
3307
+
3308
+ # Remove the branch workflow from registry and delete file
3309
+ try:
3310
+ WorkflowRegistry.delete_workflow_by_name(request.workflow_name)
3311
+ Path(branch_content_file_path).unlink()
3312
+ logger.info("Deleted branch workflow file and registry entry for '%s'", request.workflow_name)
3313
+ except Exception as delete_error:
3314
+ logger.warning(
3315
+ "Failed to fully clean up branch workflow '%s': %s",
3316
+ request.workflow_name,
3317
+ str(delete_error),
3318
+ )
3319
+ # Continue anyway - the merge was successful even if cleanup failed
3320
+
3321
+ logger.info(
3322
+ "Successfully merged branch workflow '%s' into source workflow '%s'",
3323
+ request.workflow_name,
3324
+ source_workflow_name,
3325
+ )
3326
+ return MergeWorkflowBranchResultSuccess(merged_workflow_name=source_workflow_name)
3327
+
3328
+ except Exception as e:
3329
+ logger.error(
3330
+ "Failed to merge branch workflow '%s' into source workflow '%s': %s",
3331
+ request.workflow_name,
3332
+ source_workflow_name,
3333
+ str(e),
3334
+ )
3335
+ return MergeWorkflowBranchResultFailure()
3336
+
3337
+ def on_reset_workflow_branch_request(self, request: ResetWorkflowBranchRequest) -> ResultPayload:
3338
+ """Reset a branch to match its source workflow, discarding branch changes."""
3339
+ try:
3340
+ # Validate branch workflow exists
3341
+ branch_workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3342
+ except KeyError as e:
3343
+ logger.error("Failed to reset workflow branch because it does not exist: %s", str(e))
3344
+ return ResetWorkflowBranchResultFailure()
3345
+
3346
+ # Get source workflow name from branch metadata
3347
+ source_workflow_name = branch_workflow.metadata.branched_from
3348
+ if not source_workflow_name:
3349
+ logger.error(
3350
+ "Failed to reset workflow branch '%s' because it has no source workflow",
3351
+ request.workflow_name,
3352
+ )
3353
+ return ResetWorkflowBranchResultFailure()
3354
+
3355
+ # Validate source workflow exists
3356
+ try:
3357
+ source_workflow = WorkflowRegistry.get_workflow_by_name(source_workflow_name)
3358
+ except KeyError:
3359
+ logger.error(
3360
+ "Failed to reset workflow branch '%s' because source workflow '%s' does not exist",
3361
+ request.workflow_name,
3362
+ source_workflow_name,
3363
+ )
3364
+ return ResetWorkflowBranchResultFailure()
3365
+
3366
+ try:
3367
+ # Read content from the source workflow (what we're resetting the branch to)
3368
+ source_content_file_path = WorkflowRegistry.get_complete_file_path(source_workflow.file_path)
3369
+ source_content = Path(source_content_file_path).read_text(encoding="utf-8")
3370
+
3371
+ # Create updated metadata for branch workflow - preserve branch relationship and source timestamp
3372
+ reset_metadata = WorkflowMetadata(
3373
+ name=request.workflow_name,
3374
+ schema_version=source_workflow.metadata.schema_version,
3375
+ engine_version_created_with=source_workflow.metadata.engine_version_created_with,
3376
+ node_libraries_referenced=source_workflow.metadata.node_libraries_referenced.copy(),
3377
+ workflows_referenced=source_workflow.metadata.workflows_referenced.copy()
3378
+ if source_workflow.metadata.workflows_referenced
3379
+ else None,
3380
+ description=source_workflow.metadata.description,
3381
+ image=source_workflow.metadata.image,
3382
+ is_griptape_provided=branch_workflow.metadata.is_griptape_provided,
3383
+ is_template=branch_workflow.metadata.is_template,
3384
+ creation_date=branch_workflow.metadata.creation_date,
3385
+ last_modified_date=source_workflow.metadata.last_modified_date,
3386
+ branched_from=source_workflow_name, # Preserve branch relationship
3387
+ )
3388
+
3389
+ # Replace the metadata header with reset metadata
3390
+ reset_content = self._replace_workflow_metadata_header(source_content, reset_metadata)
3391
+ if reset_content is None:
3392
+ logger.error("Failed to replace metadata header for reset branch workflow '%s'", request.workflow_name)
3393
+ return ResetWorkflowBranchResultFailure()
3394
+
3395
+ # Write the updated content to the branch workflow file
3396
+ branch_content_file_path = WorkflowRegistry.get_complete_file_path(branch_workflow.file_path)
3397
+ Path(branch_content_file_path).write_text(reset_content, encoding="utf-8")
3398
+
3399
+ # Update the registry with new metadata for the branch workflow
3400
+ branch_workflow.metadata = reset_metadata
3401
+
3402
+ except Exception as e:
3403
+ logger.error(
3404
+ "Failed to reset branch workflow '%s' to source workflow '%s': %s",
3405
+ request.workflow_name,
3406
+ source_workflow_name,
3407
+ str(e),
3408
+ )
3409
+ return ResetWorkflowBranchResultFailure()
3410
+ else:
3411
+ logger.info(
3412
+ "Successfully reset branch workflow '%s' to match source workflow '%s'",
3413
+ request.workflow_name,
3414
+ source_workflow_name,
3415
+ )
3416
+ return ResetWorkflowBranchResultSuccess(reset_workflow_name=request.workflow_name)
3417
+
3418
+ def on_compare_workflows_request(self, request: CompareWorkflowsRequest) -> ResultPayload:
3419
+ """Compare two workflows to determine if one is ahead, behind, or up-to-date relative to the other."""
3420
+ try:
3421
+ # Get the workflow to evaluate
3422
+ workflow = WorkflowRegistry.get_workflow_by_name(request.workflow_name)
3423
+ except KeyError:
3424
+ logger.error("Failed to compare workflow '%s' because it does not exist", request.workflow_name)
3425
+ return CompareWorkflowsResultFailure()
3426
+
3427
+ # Use the provided compare_workflow_name
3428
+ compare_workflow_name = request.compare_workflow_name
3429
+
3430
+ # Try to get the source workflow
3431
+ try:
3432
+ source_workflow = WorkflowRegistry.get_workflow_by_name(compare_workflow_name)
3433
+ except KeyError:
3434
+ # Source workflow no longer exists
3435
+ details = f"Source workflow '{compare_workflow_name}' for '{request.workflow_name}' no longer exists"
3436
+ return CompareWorkflowsResultSuccess(
3437
+ workflow_name=request.workflow_name,
3438
+ compare_workflow_name=compare_workflow_name,
3439
+ status="no_source",
3440
+ workflow_last_modified=workflow.metadata.last_modified_date.isoformat()
3441
+ if workflow.metadata.last_modified_date
3442
+ else None,
3443
+ source_last_modified=None,
3444
+ details=details,
3445
+ )
3446
+
3447
+ # Compare last modified dates
3448
+ workflow_last_modified = workflow.metadata.last_modified_date
3449
+ source_last_modified = source_workflow.metadata.last_modified_date
3450
+
3451
+ # Handle missing timestamps
3452
+ if workflow_last_modified is None or source_last_modified is None:
3453
+ details = f"Cannot compare timestamps - workflow: {workflow_last_modified}, source: {source_last_modified}"
3454
+ logger.warning(details)
3455
+ return CompareWorkflowsResultSuccess(
3456
+ workflow_name=request.workflow_name,
3457
+ compare_workflow_name=compare_workflow_name,
3458
+ status="diverged",
3459
+ workflow_last_modified=workflow_last_modified.isoformat() if workflow_last_modified else None,
3460
+ source_last_modified=source_last_modified.isoformat() if source_last_modified else None,
3461
+ details=details,
3462
+ )
3463
+
3464
+ # Compare timestamps to determine status
3465
+ if workflow_last_modified == source_last_modified:
3466
+ status = "up_to_date"
3467
+ details = f"Workflow '{request.workflow_name}' is up-to-date with source '{compare_workflow_name}'"
3468
+ elif workflow_last_modified > source_last_modified:
3469
+ status = "ahead"
3470
+ details = f"Workflow '{request.workflow_name}' is ahead of source '{compare_workflow_name}' (local changes)"
3471
+ else:
3472
+ status = "behind"
3473
+ details = (
3474
+ f"Workflow '{request.workflow_name}' is behind source '{compare_workflow_name}' (source has updates)"
3475
+ )
3476
+
3477
+ return CompareWorkflowsResultSuccess(
3478
+ workflow_name=request.workflow_name,
3479
+ compare_workflow_name=compare_workflow_name,
3480
+ status=status,
3481
+ workflow_last_modified=workflow_last_modified.isoformat(),
3482
+ source_last_modified=source_last_modified.isoformat(),
3483
+ details=details,
3484
+ )
3485
+
2927
3486
  def _walk_object_tree(
2928
3487
  self, obj: Any, process_class_fn: Callable[[type, Any], None], visited: set[int] | None = None
2929
3488
  ) -> None:
@@ -3076,6 +3635,101 @@ class WorkflowManager:
3076
3635
 
3077
3636
  self._walk_object_tree(obj, collect_class_import)
3078
3637
 
3638
+ def on_register_workflows_from_config_request(self, request: RegisterWorkflowsFromConfigRequest) -> ResultPayload:
3639
+ """Register workflows from a configuration section."""
3640
+ try:
3641
+ workflows_to_register = GriptapeNodes.ConfigManager().get_config_value(request.config_section)
3642
+ if not workflows_to_register:
3643
+ logger.info("No workflows found in configuration section '%s'", request.config_section)
3644
+ return RegisterWorkflowsFromConfigResultSuccess(succeeded_workflows=[], failed_workflows=[])
3645
+
3646
+ # Process all workflows and track results
3647
+ succeeded, failed = self._process_workflows_for_registration(workflows_to_register)
3648
+
3649
+ except Exception as e:
3650
+ logger.error(
3651
+ "Failed to register workflows from configuration section '%s': %s", request.config_section, str(e)
3652
+ )
3653
+ return RegisterWorkflowsFromConfigResultFailure()
3654
+ else:
3655
+ return RegisterWorkflowsFromConfigResultSuccess(succeeded_workflows=succeeded, failed_workflows=failed)
3656
+
3657
+ def _process_workflows_for_registration(self, workflows_to_register: list[str]) -> WorkflowRegistrationResult:
3658
+ """Process a list of workflow paths for registration.
3659
+
3660
+ Returns:
3661
+ WorkflowRegistrationResult with succeeded and failed workflow names
3662
+ """
3663
+ succeeded = []
3664
+ failed = []
3665
+
3666
+ for workflow_to_register in workflows_to_register:
3667
+ path = Path(workflow_to_register)
3668
+
3669
+ if path.is_dir():
3670
+ dir_result = self._process_workflow_directory(path)
3671
+ succeeded.extend(dir_result.succeeded)
3672
+ failed.extend(dir_result.failed)
3673
+ elif path.suffix == ".py":
3674
+ workflow_name = self._process_single_workflow_file(path)
3675
+ if workflow_name:
3676
+ succeeded.append(workflow_name)
3677
+ else:
3678
+ failed.append(str(path))
3679
+
3680
+ return WorkflowRegistrationResult(succeeded=succeeded, failed=failed)
3681
+
3682
+ def _process_workflow_directory(self, directory_path: Path) -> WorkflowRegistrationResult:
3683
+ """Process all workflow files in a directory.
3684
+
3685
+ Returns:
3686
+ WorkflowRegistrationResult with succeeded and failed workflow names
3687
+ """
3688
+ succeeded = []
3689
+ failed = []
3690
+
3691
+ for workflow_file in directory_path.glob("*.py"):
3692
+ # Check that the python file has script metadata
3693
+ metadata_blocks = self.get_workflow_metadata(
3694
+ workflow_file, block_name=WorkflowManager.WORKFLOW_METADATA_HEADER
3695
+ )
3696
+ if len(metadata_blocks) == 1:
3697
+ workflow_name = self._process_single_workflow_file(workflow_file)
3698
+ if workflow_name:
3699
+ succeeded.append(workflow_name)
3700
+ else:
3701
+ failed.append(str(workflow_file))
3702
+
3703
+ return WorkflowRegistrationResult(succeeded=succeeded, failed=failed)
3704
+
3705
+ def _process_single_workflow_file(self, workflow_file: Path) -> str | None:
3706
+ """Process a single workflow file for registration.
3707
+
3708
+ Returns:
3709
+ Workflow name if registered successfully, None if failed or skipped
3710
+ """
3711
+ # Parse metadata once and use it for both registration check and actual registration
3712
+ load_metadata_request = LoadWorkflowMetadata(file_name=str(workflow_file))
3713
+ load_metadata_result = self.on_load_workflow_metadata_request(load_metadata_request)
3714
+
3715
+ if not isinstance(load_metadata_result, LoadWorkflowMetadataResultSuccess):
3716
+ logger.debug("Skipping workflow with invalid metadata: %s", workflow_file)
3717
+ return None
3718
+
3719
+ workflow_metadata = load_metadata_result.metadata
3720
+
3721
+ # Check if workflow is already registered using the parsed metadata
3722
+ if WorkflowRegistry.has_workflow_with_name(workflow_metadata.name):
3723
+ logger.debug("Skipping already registered workflow: %s", workflow_file)
3724
+ return None
3725
+
3726
+ # Register workflow using existing method with parsed metadata available
3727
+ # The _register_workflow method will re-parse metadata, but this is acceptable
3728
+ # since we've already validated it's parseable and the duplicate work is minimal
3729
+ if self._register_workflow(str(workflow_file)):
3730
+ return workflow_metadata.name
3731
+ return None
3732
+
3079
3733
 
3080
3734
  class ASTContainer:
3081
3735
  """ASTContainer is a helper class to keep track of AST nodes and generate final code from them."""